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 React Router avec Turnstile

Protégez un formulaire React Router du spam : une action de route avec Turnstile, une limite Workers basée sur cf-connecting-ip, un module .server et un en-tête CSP.

Preet SutharFondateur, Ikiform

Faites les vérifications dans l'action de la route : limitez par l'IP client, vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot. Gardez les helpers dans un module .server.ts pour qu'ils n'atteignent jamais le navigateur, et définissez la CSP depuis l'export headers de la route. Sur Cloudflare Workers, le binding de limitation de débit et les secrets viennent de env dans cloudflare:workers.

Ce qui change avec React Router

En mode framework, chaque route dotée d'une action accepte des POST, de votre formulaire ou de n'importe qui d'autre. React Router vous donne trois outils qui déterminent la place des vérifications anti-spam :

  • Les modules .server. Un fichier nommé spam.server.ts ne peut pas être inclus dans le code client : les secrets et les appels à siteverify restent donc sur le serveur même quand un composant importe depuis la même route.
  • L'export headers. Les en-têtes renvoyés par une action ne sont pas envoyés sur une réponse de document, sauf si la fonction headers de la route les transmet. La CSP vit là aussi.
  • useFetcher. Il envoie vers l'action sans navigation : la page et son widget restent donc montés entre les tentatives. C'est pourquoi le widget est remonté avec une key après chaque réponse.

Cette page déploie sur Cloudflare Workers, un hébergement courant pour React Router, où un binding de limitation de débit est intégré. Le tableau de déploiement couvre Vercel et Netlify.

Comment un envoi atteint l'action React Router, avec et sans JavaScriptHydraté : useFetcherAvant l'hydratation : un POST de documentfetcher.Formpas denavigationactionlimite,siteverify,transmissionfetcher.dataerreur ouremerciementnouveauwidgetla key changeenvoi deformulairesimpleaucun widgetrenduactionsiteverifyrefuseexportheadersCSP,Retry-AfteractionDataerreuraffichéePOSTdata()remontagePOSTrenduComment un envoi atteint l'action React Router, avec et sans JavaScriptHydraté : useFetcherAvant l'hydratation : un POST de documentfetcher.Formpas de navigationactionlimite, siteverify, transmissionfetcher.dataerreur ou remerciementnouveau widgetla key changeenvoi de formulairesimpleaucun widget renduactionsiteverify refuseexport headersCSP, Retry-AfteractionDataerreur affichéePOSTdata()remontagePOSTrendu
  1. Une fois hydraté, le formulaire envoie via useFetcher : la page ne navigue donc pas.
  2. L'action limite le débit par IP, vérifie le token Turnstile et transmet les champs.
  3. Son résultat arrive dans fetcher.data.
  4. La key du widget change : un nouveau widget, et un nouveau token, est donc prêt pour la tentative suivante.
  5. Avant l'hydratation, le même formulaire envoie une requête de document normale, sans token Turnstile.
  6. L'action la refuse à l'étape siteverify.
  7. L'export headers transmet Retry-After et la CSP à la réponse de document.
  8. Le composant lit actionData au lieu de fetcher.data et affiche l'erreur.
Une vérification humaine a besoin de JavaScript : le chemin sans JavaScript aboutit donc à une erreur claire plutôt qu'à un passage silencieux.

Mise en place du projet

Partez du modèle React Router de Cloudflare avec @cloudflare/vite-plugin. Dans React Router 8, l'argument context est toujours un RouterContextProvider, et un simple objet { cloudflare: { env } } venant de l'entrée du Worker lève une erreur : l'entrée passe donc la requête seule et la route lit les bindings avec import { env } from "cloudflare:workers". Les fichiers que touche cette page :

Fichiers
app/
  routes/
    contact.tsx
  components/
    turnstile.tsx
  lib/
    spam.server.ts
workers/
  app.ts
wrangler.jsonc
.dev.vars
workers/app.ts
import { createRequestHandler } from "react-router";

const handler = createRequestHandler(() => import("virtual:react-router/server-build"), import.meta.env.MODE);

export default {
  fetch(request) {
    return handler(request);
  },
} satisfies ExportedHandler<Env>;
wrangler.jsonc
{
  "name": "acme-site",
  "main": "./workers/app.ts",
  "vars": { "TURNSTILE_SITE_KEY": "1x00000000000000000000AA" },
  "ratelimits": [
    {
      "name": "CONTACT_LIMIT",
      "namespace_id": "1001",
      "simple": { "limit": 5, "period": 60 }
    }
  ]
}
.dev.vars
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
  • La site key est publique : elle se trouve donc dans vars. Le loader la transmet au composant : le build client n'a donc besoin d'aucune variable d'environnement.
  • Les secrets vont dans .dev.vars en local et via wrangler secret put en production, jamais dans wrangler.jsonc.
  • Lancez wrangler types après avoir modifié la configuration, pour que CONTACT_LIMIT et les secrets soient typés sur env.

Les helpers côté serveur uniquement

app/lib/spam.server.ts
export async function isHuman(secret: string, token: string, ip: string, host: string) {
  if (!token || token.length > 2048) return false;
  const body = new URLSearchParams({ secret, response: token, remoteip: ip });
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
    const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
    return verdict.success && verdict.action === "contact" && (import.meta.env.DEV || verdict.hostname === host);
  } catch {
    return false;
  }
}

export async function forward(secretKey: string, form: FormData, origin: string | null) {
  const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${secretKey}` });
  if (origin) headers.set("Origin", origin);
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", { method: "POST", headers, body: form }).catch(() => null);
  return response?.ok ?? false;
}
  • Le suffixe .server fait échouer le build si du code client devait inclure ce module.
  • Siteverify accepte des corps form-encoded. La vérification du hostname compare avec l'hôte vers lequel le visiteur a envoyé et est sautée en développement, car les tokens de test portent un hostname factice.
  • forward envoie le FormData d'origine : chaque champ, honeypot compris, atteint donc Ikiform tel qu'il a été envoyé.
  • Il transmet aussi l'Origin du visiteur, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuis localhost.
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 widget

app/components/turnstile.tsx
import { useEffect, useRef } from "react";

const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";

interface TurnstileApi {
  render(container: HTMLElement, options: { sitekey: string; action: string }): string;
  remove(widgetId: string): void;
}

declare global {
  interface Window {
    turnstile?: TurnstileApi;
  }
}

export function Turnstile({ siteKey }: { siteKey: string }) {
  const box = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let widgetId: string | undefined;
    const mount = () => {
      if (box.current && window.turnstile) widgetId = window.turnstile.render(box.current, { sitekey: siteKey, action: "contact" });
    };
    if (window.turnstile) mount();
    else document.querySelector(`script[src="${SCRIPT}"]`)?.addEventListener("load", mount, { once: true });
    return () => {
      if (widgetId) window.turnstile?.remove(widgetId);
    };
  }, [siteKey]);

  return (
    <>
      <script src={SCRIPT} async />
      <div ref={box} />
    </>
  );
}

React 19 remonte un <script> async dans le head et le charge une seule fois, quel que soit le nombre de rendus du composant. L'effet rend le widget explicitement, ce que Cloudflare recommande pour les applications monopages, et le retire au démontage.

La route

app/routes/contact.tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import { env } from "cloudflare:workers";
import { Turnstile } from "~/components/turnstile";
import { forward, isHuman } from "~/lib/spam.server";
import type { Route } from "./+types/contact";

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 function loader() {
  return { siteKey: env.TURNSTILE_SITE_KEY };
}

export async function action({ request }: Route.ActionArgs) {
  const ip = request.headers.get("cf-connecting-ip") ?? "unknown";

  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  if (!success) {
    return data({ ok: false, error: "Too many messages. Try again in a minute." }, { status: 429, headers: { "Retry-After": "60" } });
  }

  const form = await request.formData();
  const token = String(form.get("cf-turnstile-response") ?? "");
  if (!(await isHuman(env.TURNSTILE_SECRET_KEY, token, ip, new URL(request.url).hostname))) {
    return data({ ok: false, error: "We couldn't confirm you're human. Try again." }, { status: 403 });
  }

  form.delete("cf-turnstile-response");
  const sent = await forward(env.IKIFORM_SECRET_KEY, form, request.headers.get("origin"));
  return sent ? { ok: true, error: "" } : data({ ok: false, error: "Your message didn't send. Try again." }, { status: 502 });
}

export function headers({ actionHeaders, parentHeaders }: Route.HeadersArgs) {
  parentHeaders.set("Content-Security-Policy", POLICY);
  const retry = actionHeaders.get("Retry-After");
  if (retry) parentHeaders.set("Retry-After", retry);
  return parentHeaders;
}

export default function Contact({ loaderData, actionData }: Route.ComponentProps) {
  const fetcher = useFetcher<typeof action>();
  const result = fetcher.data ?? actionData;
  const [round, setRound] = useState(0);

  useEffect(() => {
    if (fetcher.state === "idle" && fetcher.data) setRound((count) => count + 1);
  }, [fetcher.state, fetcher.data]);

  if (result?.ok) return <p role="status">Thanks. We'll reply by email.</p>;

  return (
    <fetcher.Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required maxLength={120} />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={5} required minLength={10} />
      <div className="hp" aria-hidden="true">
        <label htmlFor="hp-field">Leave this empty</label>
        <input id="hp-field" name="_gotcha" type="text" tabIndex={-1} autoComplete="off" />
      </div>
      <Turnstile key={round} siteKey={loaderData.siteKey} />
      {result?.error ? <p role="alert">{result.error}</p> : null}
      <button disabled={fetcher.state !== "idle"}>{fetcher.state === "idle" ? "Send message" : "Sending"}</button>
    </fetcher.Form>
  );
}
  • La limite s'exécute avant request.formData() : un flot ne coûte donc jamais d'analyse de corps ni d'appel à siteverify.
  • cf-connecting-ip est défini par Cloudflare sur chaque requête vers un Worker et ne peut pas être défini par le visiteur.
  • data() définit le statut et Retry-After. Un fetcher lit directement le corps ; c'est l'export headers qui porte ces en-têtes sur une réponse de document.
  • result se rabat sur actionData : l'erreur s'affiche donc quand même si le formulaire a été envoyé avant l'hydratation.
  • La classe .hp masque le honeypot hors écran, comme dans le guide du honeypot.

React Router rend des scripts inline pour l'hydratation : cette politique garde donc 'unsafe-inline' dans script-src. Pour vous en passer, générez un nonce dans entry.server.tsx et passez-le à <Scripts> et au script Turnstile ; d'après Cloudflare, Turnstile transmet un nonce à ce qu'il charge.

Tester en local

D'après la documentation de Cloudflare, son plugin Vite exécute votre code serveur dans le runtime Workers pendant le développement : env de cloudflare:workers, .dev.vars et le binding de limitation de débit se comportent donc comme en production. En local, aucun edge Cloudflare ne définit cf-connecting-ip : chaque requête compte donc comme unknown et partage une même limite. C'est normal. Pour voir le vrai en-tête, déployez une préproduction avec wrangler versions upload et testez depuis deux réseaux.

Déployer ailleurs

HébergeurIP clientLimiteurSecrets
Cloudflare Workerscf-connecting-ipBinding de limitation de débitwrangler secret put
Vercelx-forwarded-for, écrasé par VercelRedisprocess.env
Netlifyx-nf-client-connection-ipRedisprocess.env

Cloudflare documente le binding comme approximatif et compté par emplacement, ce qui convient pour un formulaire de contact. Hors de Workers, il n'y a pas de module cloudflare:workers : lisez donc les secrets dans process.env et remplacez le binding par un compteur Redis comme celui de la page sur la limitation de débit.

Laisser Ikiform faire les vérifications

Si gérer des clés, un binding et une CSP pour un seul formulaire, c'est plus que vous n'en voulez, faites pointer un formulaire simple vers votre endpoint Ikiform et supprimez l'action :

app/routes/contact.tsx
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label htmlFor="email">Email</label>
  <input id="email" name="email" type="email" autoComplete="email" required />
  <label htmlFor="message">Message</label>
  <textarea id="message" name="message" rows={5} required />
  <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
  <button>Send message</button>
</form>

Utilisez un <form> en minuscules, pas le <Form> de React Router, pour que le navigateur envoie directement vers Ikiform. Réglez Spam check 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. Choisissez le Rate limit Strict, 2 toutes les 10 minutes par visiteur, et listez votre domaine sous Allowed domains. Le spam attrapé reçoit la même réponse qu'un vrai message et est conservé 30 jours. Le guide d'installation serveur explique comment garder plutôt votre propre action.

Vérifiez que tout fonctionne

  1. Lancez npm run dev et envoyez un message avec les clés de test. Il apparaît sous Submissions. Un espace de travail sans offre accepte 50 tests depuis localhost par mois.
  2. Envoyez un second message sans recharger. Un nouveau widget apparaît après la première réponse et le second envoi réussit.
  3. Désactivez JavaScript et envoyez. La page se recharge avec l'erreur de vérification humaine.
  4. Envoyez six fois en une minute après le déploiement. Le sixième reçoit 429 avec Retry-After: 60.
  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
Server-only module referenced by clientUn composant importe depuis spam.server.ts quelque chose qu'il affiche. Seuls loader et action peuvent l'utiliser.
La deuxième tentative échoue toujours à siteverifyLe widget a gardé son token déjà utilisé. Assurez-vous que sa key change après chaque réponse du fetcher.
Invalid context value provided to handleRequestL'entrée du Worker passe encore { cloudflare: { env, ctx } }, comme le faisaient les modèles React Router 7. Appelez le handler avec la requête seule et lisez les bindings depuis cloudflare:workers.
Retry-After manque dans la réponse sans JavaScriptLes en-têtes de l'action sont perdus sauf si l'export headers les renvoie. Copiez-les depuis actionHeaders comme montré.

Questions

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