Arrêter le spam des formulaires de contact

Arrêter le spam des formulaires de contact8 min de lecture

Protection anti-spam d'un formulaire Nuxt avec Turnstile

Protégez un formulaire Nuxt du spam : une server route Nitro avec Turnstile, une limite par IP dans le stockage Nitro, des secrets runtimeConfig et une CSP dans routeRules.

Preet SutharFondateur, Ikiform

Envoyez le formulaire avec $fetch vers une server route qui compte les tentatives par IP client dans le stockage Nitro, vérifie le token Turnstile avec siteverify et transmet les champs avec le honeypot. Gardez les secrets dans un runtimeConfig privé, défini à l'exécution par des variables d'environnement NUXT_. Envoyez la CSP de la page de contact depuis routeRules, en autorisant challenges.cloudflare.com pour le script et son cadre.

Ce qui change avec Nuxt

Le serveur de Nuxt est Nitro, et Nitro apporte deux choses dont une vérification anti-spam a besoin. Les server routes de server/api sont de simples endpoints auxquels $fetch est déjà branché, et useStorage() vous donne un stockage clé-valeur dont vous choisissez le backend par environnement : la mémoire en développement, Redis ou un autre driver en production. La limite de débit devient ainsi un changement de configuration plutôt qu'une dépendance.

Deux habitudes de Nuxt posent problème ici. getRequestIP(event, { xForwardedFor: true }) renvoie la première entrée de X-Forwarded-For, que la documentation de h3 elle-même qualifie d'entrée client. Et les fichiers .env ne sont pas lus en production : les secrets doivent donc être définis comme variables d'environnement de la plateforme.

Un envoi de contact via la server route Nuxt et le stockage NitroContactForm.vue$fetchavecletokenclientIp(event)en-têtedepuisruntimeConfiguseStoragecomptercetteminutesiteverifyactionethôteIkiformstocke,notelehoneypot429secondesrestantesPOST /api/contactmoins de 5humainau-delàUn envoi de contact via la server route Nuxt et le stockage NitroContactForm.vue$fetch avec le tokenclientIp(event)en-tête depuisruntimeConfiguseStoragecompter cetteminutesiteverifyaction et hôteIkiformstocke, note lehoneypot429secondesrestantesPOST /api/contactmoins de 5humainau-delà
  1. Le composant envoie les champs et le token Turnstile vers /api/contact avec $fetch.
  2. La server route lit l'IP client dans l'en-tête nommé dans runtimeConfig.
  3. Elle compte les envois de l'IP pour la minute en cours dans le stockage Nitro. Au-delà de cinq, elle lève un 429 avec les secondes restantes.
  4. Sous la limite, elle vérifie le token avec siteverify, en contrôlant l'action et l'hôte.
  5. Puis elle transmet les champs à Ikiform, qui les stocke et note le honeypot.
En développement, le montage de stockage est en mémoire. En production, le même code parle à Redis, car nuxt.config monte un autre driver.

Mise en place du projet

Fichiers
app/
  components/
    ContactForm.vue
  composables/
    useTurnstile.ts
server/
  api/
    contact.post.ts
  utils/
    spam.ts
nuxt.config.ts
.env
.env
NUXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
NUXT_TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
NUXT_IKIFORM_SECRET_KEY=ik_sk_your_secret_key
NUXT_CLIENT_IP_HEADER=x-forwarded-for
nuxt.config.ts
const policy = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com",
  "frame-src https://challenges.cloudflare.com",
  "object-src 'none'",
  "base-uri 'self'",
].join("; ");

export default defineNuxtConfig({
  runtimeConfig: {
    turnstileSecretKey: "",
    ikiformSecretKey: "",
    clientIpHeader: "x-forwarded-for",
    public: {
      turnstileSiteKey: "",
    },
  },
  routeRules: {
    "/contact": { headers: { "Content-Security-Policy": policy } },
  },
  $production: {
    nitro: {
      storage: {
        ratelimit: { driver: "upstash" },
      },
    },
  },
});
  • Chaque clé de runtimeConfig peut être remplacée à l'exécution par une variable NUXT_ : turnstileSecretKey par NUXT_TURNSTILE_SECRET_KEY, public.turnstileSiteKey par NUXT_PUBLIC_TURNSTILE_SITE_KEY. Les clés hors de public n'atteignent jamais le navigateur.
  • routeRules n'envoie la CSP que sur /contact. Nuxt insère inline son payload et ses scripts d'hydratation : cette politique garde donc 'unsafe-inline' ; le module communautaire nuxt-security peut générer des nonces si vous avez besoin d'une politique plus stricte.
  • Le driver upstash n'est monté qu'en production et lit UPSTASH_REDIS_REST_URL et UPSTASH_REDIS_REST_TOKEN dans l'environnement. Il a besoin du paquet @upstash/redis : installez-le avec npm install @upstash/redis.

Utilitaires serveur

Les fichiers de server/utils sont importés automatiquement dans chaque server route :

server/utils/spam.ts
import type { H3Event } from "h3";

const WINDOW_SECONDS = 60;
const LIMIT = 5;

export function clientIp(event: H3Event) {
  const header = useRuntimeConfig(event).clientIpHeader;
  return getRequestHeader(event, header)?.split(",")[0]?.trim() || "unknown";
}

export async function secondsToWait(ip: string) {
  const storage = useStorage("ratelimit");
  const window = Math.floor(Date.now() / 1000 / WINDOW_SECONDS);
  const key = `contact:${ip}:${window}`;
  const count = ((await storage.getItem<number>(key)) ?? 0) + 1;
  await storage.setItem(key, count);
  if (count <= LIMIT) return 0;
  return (window + 1) * WINDOW_SECONDS - Math.floor(Date.now() / 1000);
}

export async function isHuman(event: H3Event, token: string, ip: string) {
  if (!token || token.length > 2048) return false;
  try {
    const verdict = await $fetch<{ success: boolean; hostname?: string; action?: string }>("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      body: { secret: useRuntimeConfig(event).turnstileSecretKey, response: token, remoteip: ip },
    });
    return verdict.success && verdict.action === "contact" && (import.meta.dev || verdict.hostname === getRequestHost(event));
  } catch {
    return false;
  }
}
  • clientIp lit exactement un en-tête, celui que votre hébergeur écrase. Réglez NUXT_CLIENT_IP_HEADER sur x-forwarded-for sur Vercel, x-nf-client-connection-ip sur Netlify et cf-connecting-ip sur Cloudflare.
  • Le compteur est une fenêtre fixe dans le stockage Nitro. La lecture puis l'écriture ne sont pas atomiques : deux envois au même instant peuvent donc tous deux passer sous la limite ; pour un formulaire de contact, c'est acceptable. Les clés n'expirent pas d'elles-mêmes : donnez donc une politique d'éviction à la base Redis.
  • $fetch envoie le corps de siteverify en JSON, ce que siteverify accepte. La vérification de l'hôte utilise getRequestHost et est sautée en développement, car les tokens de test portent un hostname factice.

La server route

server/api/contact.post.ts
interface ContactBody {
  name?: string;
  email?: string;
  company?: string;
  message?: string;
  _gotcha?: string;
  "cf-turnstile-response"?: string;
}

export default defineEventHandler(async (event) => {
  const ip = clientIp(event);
  const wait = await secondsToWait(ip);
  if (wait) throw createError({ status: 429, message: `Too many messages. Try again in ${wait} seconds.` });

  const body = await readBody<ContactBody>(event);
  const fields = {
    name: body.name?.trim().slice(0, 120) ?? "",
    email: body.email?.trim() ?? "",
    company: body.company?.trim().slice(0, 120) ?? "",
    message: body.message?.trim().slice(0, 5000) ?? "",
    _gotcha: body._gotcha ?? "",
  };
  if (!fields.email.includes("@")) throw createError({ status: 400, message: "Enter an address we can reply to." });
  if (!(await isHuman(event, body["cf-turnstile-response"] ?? "", ip))) {
    throw createError({ status: 403, message: "We couldn't confirm you're human. Try again." });
  }

  const origin = getRequestHeader(event, "origin");
  await $fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${useRuntimeConfig(event).ikiformSecretKey}`,
      ...(origin ? { Origin: origin } : {}),
    },
    body: fields,
  }).catch((failure) => {
    throw createError({
      status: failure.status ?? 502,
      message: failure.data?.error?.message ?? "Your message didn't send. Try again.",
    });
  });
  return { sent: true };
});
  • Le suffixe .post.ts fait que Nitro ne répond qu'à POST. Tout le reste reçoit 405.
  • La limite s'exécute avant readBody : un flot ne coûte donc jamais d'analyse de corps ni d'appel à siteverify.
  • Chaque échec lève createError. Le corps JSON porte message, que le composant affiche.
  • L'Origin du visiteur est transmis, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuis localhost.
  • Le honeypot _gotcha suit tel quel : Ikiform stocke donc un honeypot rempli comme spam au lieu de le jeter.
Les versions côté serveur envoient avec une clé secrète, donc Ikiform ignore Allowed domains et sa vérification humaine. Chaque envoi vient alors de l'IP de votre serveur, et la limite Standard de 5 par minute et par visiteur compte tous vos visiteurs ensemble. Choisissez la limite de débit Relaxed ou Custom pour un formulaire que vous envoyez via un serveur, et gardez IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.

Le composable et le formulaire

app/composables/useTurnstile.ts
interface TurnstileApi {
  render(
    container: HTMLElement,
    options: { sitekey: string; action: string; callback: (token: string) => void; "expired-callback": () => void },
  ): string;
  reset(widgetId: string): void;
  remove(widgetId: string): void;
}

let loading: Promise<TurnstileApi> | undefined;

function load() {
  loading ??= new Promise<TurnstileApi>((resolve, reject) => {
    const script = document.createElement("script");
    script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
    script.async = true;
    script.onload = () => {
      const api = (window as Window & { turnstile?: TurnstileApi }).turnstile;
      if (api) resolve(api);
      else reject(new Error("Turnstile did not load"));
    };
    script.onerror = () => reject(new Error("Turnstile did not load"));
    document.head.append(script);
  });
  return loading;
}

export function useTurnstile(action: string) {
  const { turnstileSiteKey } = useRuntimeConfig().public;
  const box = ref<HTMLElement>();
  const token = ref("");
  let api: TurnstileApi | undefined;
  let widgetId: string | undefined;

  onMounted(async () => {
    api = await load();
    if (!box.value) return;
    widgetId = api.render(box.value, {
      sitekey: turnstileSiteKey,
      action,
      callback: (value) => (token.value = value),
      "expired-callback": () => (token.value = ""),
    });
  });

  onBeforeUnmount(() => {
    if (widgetId) api?.remove(widgetId);
  });

  function reset() {
    token.value = "";
    if (widgetId) api?.reset(widgetId);
  }

  return { box, token, reset };
}
app/components/ContactForm.vue
<script setup lang="ts">
const fields = reactive({ name: "", email: "", company: "", message: "", _gotcha: "" });
const { box, token, reset } = useTurnstile("contact");
const status = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");

async function send() {
  status.value = "sending";
  error.value = "";
  try {
    await $fetch("/api/contact", { method: "POST", body: { ...fields, "cf-turnstile-response": token.value } });
    status.value = "sent";
  } catch (failure) {
    error.value = (failure as { data?: { message?: string } }).data?.message ?? "Couldn't send your message. Try again.";
    status.value = "idle";
    reset();
  }
}
</script>

<template>
  <p v-if="status === 'sent'" role="status">Thanks. We'll reply by email.</p>
  <form v-else @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="fields.name" autocomplete="name" required maxlength="120" />
    <label for="email">Email</label>
    <input id="email" v-model="fields.email" type="email" autocomplete="email" required />
    <label for="company">Company <span>(optional)</span></label>
    <input id="company" v-model="fields.company" autocomplete="organization" maxlength="120" />
    <label for="message">Message</label>
    <textarea id="message" v-model="fields.message" rows="5" required minlength="10" />
    <div class="hp" aria-hidden="true">
      <label for="hp-field">Leave this empty</label>
      <input id="hp-field" v-model="fields._gotcha" type="text" tabindex="-1" autocomplete="off" />
    </div>
    <div ref="box" />
    <p v-if="error" role="alert">{{ error }}</p>
    <button :disabled="status === 'sending' || !token">{{ status === "sending" ? "Sending" : "Send message" }}</button>
  </form>
</template>
  • Le composable ne s'exécute que dans onMounted : le rendu serveur ne touche donc jamais window.
  • Le bouton attend un token, et reset() en demande un nouveau après chaque échec, car un token ne sert qu'une fois et vit 300 secondes.
  • Ce formulaire a besoin de JavaScript. Pour une version qui envoie sans, voyez la section suivante.
  • La classe .hp masque le honeypot hors écran, comme dans le guide du honeypot.

Tester en local

En développement, le montage ratelimit est en mémoire : le compteur se réinitialise donc au redémarrage de nuxt dev. Vous pouvez observer la limite sans navigateur. Les envois sans token échouent à siteverify avec 403, et chacun compte quand même :

Terminal
for i in 1 2 3 4 5 6; do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" -d '{"email":"test@example.com"}'
done

Les cinq premières lignes affichent 403 et la sixième 429. En local, aucun proxy ne définit l'en-tête d'IP : chaque requête compte donc comme unknown ; c'est normal, et c'est pourquoi l'en-tête vit dans la configuration plutôt que dans le code.

Déploiement

HébergeurNUXT_CLIENT_IP_HEADERDriver de stockage
Vercelx-forwarded-forupstash, depuis le Vercel Marketplace
Netlifyx-nf-client-connection-ipupstash ou redis
Cloudflarecf-connecting-ipupstash ; Workers KV est à cohérence éventuelle, donc les compteurs sont en retard
Serveur NodeL'en-tête que définit votre proxyredis, ou la mémoire sur une seule instance

Définissez chaque variable NUXT_ dans le tableau de bord de l'hébergeur. Nuxt les lit au démarrage du serveur : changer une clé demande donc un redémarrage, pas un rebuild. Ajoutez chaque hostname de production et de préproduction au widget Turnstile.

Laisser Ikiform faire les vérifications

Sans la server route, la page de contact peut être pré-rendue avec le reste d'un site nuxt generate. Faites pointer un formulaire simple vers votre endpoint Ikiform avec method="post" et action réglé sur l'URL de votre endpoint, et supprimez server/api/contact.post.ts ainsi que le composable.

Réglez le Spam check du formulaire sur Ikiform. Un envoi sans token reçoit une courte page de vérification sur ikiform.com qui exécute Turnstile et transmet les champs : votre CSP n'a donc besoin d'aucune entrée Cloudflare. Choisissez le Rate limit Strict, 2 toutes les 10 minutes par visiteur, et listez votre domaine sous Allowed domains. Ikiform conserve le spam attrapé pendant 30 jours avec les raisons pour lesquelles il a été attrapé. Le guide d'installation Nuxt couvre l'endpoint et les clés.

Vérifiez que tout fonctionne

  1. Lancez nuxt dev avec les clés de test et envoyez un message. Il apparaît sous Submissions. Un espace de travail sans offre accepte 50 tests depuis localhost par mois.
  2. Envoyez six messages en une minute. Le sixième indique combien de secondes il reste.
  3. Réglez NUXT_TURNSTILE_SECRET_KEY sur le secret qui échoue toujours et redémarrez. Chaque envoi affiche l'erreur de vérification humaine.
  4. Requêtez /contact avec curl -I après nuxt build. La réponse porte votre Content-Security-Policy.
  5. Envoyez avec le honeypot rempli, _gotcha=bot. L'envoi reçoit quand même une réponse de succès, mais la soumission est stockée comme spam et n'envoie ni e-mail ni webhook.

Pièges

ProblèmeQue faire
Les secrets sont vides en production.env n'est pas lu à l'exécution en production. Définissez NUXT_TURNSTILE_SECRET_KEY et les autres dans l'environnement de l'hébergeur.
La limite se réinitialise à chaque déploiement ou instanceLe montage de stockage est toujours en mémoire. Vérifiez que $production.nitro.storage monte un driver partagé et que ses identifiants sont définis.
Un script contourne la limite en changeant les en-têtesVous lisez getRequestIP avec xForwardedFor: true chez un hébergeur qui n'écrase pas cet en-tête. Lisez l'en-tête propre à l'hébergeur via clientIp.
window is not defined pendant le rendu serveurDu code Turnstile s'est exécuté hors de onMounted. Gardez chaque accès à window dans les hooks de cycle de vie du composable.

Questions

Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.