Arrêter le spam des formulaires de contact

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

Protection anti-spam d'un formulaire TanStack Start avec Turnstile

Protégez un formulaire TanStack Start du spam : un middleware de fonction pour l'IP client, un token bucket, Turnstile vérifié dans une server function, et une CSP.

Preet SutharFondateur, Ikiform

Ajoutez un middleware de fonction qui lit une fois l'IP client et l'origine et les passe à chaque server function comme contexte. Dans la server function, validez l'entrée, prenez un jeton dans un bucket par IP, vérifiez le token Turnstile avec siteverify et transmettez les champs avec le honeypot. Gardez le middleware CSRF qu'installe Start, et rajoutez-le vous-même si vous créez src/start.ts.

Ce qui change avec TanStack Start

Une server function est un endpoint RPC same-origin. Start la protège des autres sites avec un middleware CSRF qui vérifie Sec-Fetch-Site, Origin ou Referer, ce qui empêche une page ailleurs d'envoyer via le navigateur d'un visiteur. Cela n'arrête pas un script, qui peut envoyer les en-têtes qu'il veut. Les vérifications anti-spam ont donc toujours leur place dans la fonction.

La brique de Start prévue pour cela est le middleware. Un middleware de fonction s'exécute avant le validateur et le handler, peut lire les en-têtes de la requête, et transmet des valeurs au handler via context. Écrivez la lecture de l'IP une fois et chaque formulaire du site en profite.

La chaîne de middlewares devant la server function TanStack StartAvant votre codeDans la server functionPOST/_serverFnduformulaireou d'unscriptmiddlewareCSRFmêmeorigine ?middlewarevisiteurcontext :ip, originvalidateurnettoie,limite leslongueurstokenbucket3 essais, 1de plustoutes les20 ssiteverifyactionvautsupportIkiformstocke,note lehoneypotrésultat :plustardouijeton restanthumainvideLa chaîne de middlewares devant la server function TanStack StartAvant votre codeDans la server functionPOST/_serverFndu formulaire oud'un scriptmiddlewareCSRFmême origine ?middlewarevisiteurcontext : ip, originvalidateurnettoie, limite leslongueurstoken bucket3 essais, 1 de plustoutes les 20 ssiteverifyaction vaut supportIkiformstocke, note lehoneypotrésultat :plustardouijeton restanthumainvide
  1. Le navigateur, ou un script, envoie vers l'URL de la server function.
  2. Le middleware CSRF de Start refuse les envois dont il ne peut pas prouver qu'ils viennent de votre propre origine.
  3. Le middleware visiteur lit l'IP client et l'en-tête Origin et les transmet comme contexte.
  4. Le validateur normalise l'entrée avant que le handler ne la voie.
  5. Le handler prend un jeton dans le bucket de cette IP. S'il n'en reste aucun, il renvoie un résultat « réessayez plus tard ».
  6. Il vérifie le token Turnstile, puis transmet les champs à Ikiform, qui les stocke et note le honeypot.
Chaque couche coûte moins cher que la suivante. Un envoi refusé n'atteint jamais siteverify ni Ikiform.

Mise en place du projet

Fichiers
src/
  routes/
    support.tsx
  lib/
    use-turnstile.ts
  server/
    bucket.ts
    visitor.ts
    support.ts
  start.ts
.env
.env
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
CLIENT_IP_HEADER=x-forwarded-for
  • Vite n'expose au navigateur que les variables préfixées par VITE_, lues via import.meta.env. Les secrets n'ont pas de préfixe : ils n'existent donc que côté serveur, dans process.env.
  • Les clés de test passent sur n'importe quel hôte, y compris localhost. Remplacez-les par de vraies clés pour chaque environnement.

Un middleware de fonction pour l'IP client

src/server/visitor.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";

const HEADER = process.env.CLIENT_IP_HEADER ?? "x-forwarded-for";

export const visitor = createMiddleware({ type: "function" }).server(async ({ next }) => {
  const ip = getRequestHeader(HEADER)?.split(",")[0]?.trim() || "unknown";
  return next({ context: { ip, origin: getRequestHeader("origin") ?? null } });
});

Réglez CLIENT_IP_HEADER sur l'en-tête que votre hébergeur écrase : x-forwarded-for sur Vercel, x-nf-client-connection-ip sur Netlify, que recommande l'équipe de Netlify même si sa documentation ne décrit que context.ip dans les Functions, et cf-connecting-ip sur Cloudflare. Sur votre propre serveur Node derrière nginx, utilisez l'en-tête que définit nginx et assurez-vous que l'application n'est pas joignable en le contournant. Le middleware transmet aussi l'Origin du visiteur, car le fetch côté serveur vers Ikiform n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuis localhost.

Un token bucket par IP

Une fenêtre fixe permet à un script d'envoyer un quota complet à la fin d'une minute et un autre au début de la suivante. Un token bucket recharge un essai à la fois : quelqu'un qui corrige une faute de frappe attend donc quelques secondes, pas une minute :

src/server/bucket.ts
const CAPACITY = 3;
const REFILL_MS = 20_000;
const buckets = new Map<string, { tokens: number; at: number }>();

export function take(key: string, now = Date.now()) {
  const saved = buckets.get(key) ?? { tokens: CAPACITY, at: now };
  const tokens = Math.min(CAPACITY, saved.tokens + (now - saved.at) / REFILL_MS);
  if (tokens < 1) {
    buckets.set(key, { tokens, at: now });
    return { ok: false, retryAfter: Math.ceil(((1 - tokens) * REFILL_MS) / 1000) };
  }
  if (buckets.size > 50_000) buckets.clear();
  buckets.set(key, { tokens: tokens - 1, at: now });
  return { ok: true, retryAfter: 0 };
}

Ce compteur vit en mémoire, ce qui est exact sur un seul serveur qui tourne en continu, comme node .output/server/index.mjs dans un conteneur. Sur Vercel ou Netlify, chaque instance de fonction garde sa propre map : la limite réelle est donc inconnue. Déplacez-y le bucket vers Redis, comme le montre la page sur la limitation de débit, en gardant la même forme take(key).

La server function

src/server/support.ts
import { createServerFn } from "@tanstack/react-start";
import { take } from "./bucket";
import { visitor } from "./visitor";

const TOPICS = ["billing", "bug", "account", "other"];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface SupportInput {
  email: string;
  topic: string;
  message: string;
  _gotcha: string;
  token: string;
}

export type SupportResult = { ok: true } | { ok: false; field?: "email" | "message"; message: string };

async function isHuman(token: string, ip: string) {
  try {
    const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
    });
    const verdict = (await response.json()) as { success: boolean; action?: string };
    return verdict.success && verdict.action === "support";
  } catch {
    return false;
  }
}

export const sendSupportRequest = createServerFn({ method: "POST" })
  .middleware([visitor])
  .validator((input: SupportInput) => ({
    email: String(input.email ?? "").trim(),
    topic: TOPICS.includes(input.topic) ? input.topic : "other",
    message: String(input.message ?? "").trim().slice(0, 5000),
    _gotcha: String(input._gotcha ?? ""),
    token: String(input.token ?? "").slice(0, 2048),
  }))
  .handler(async ({ data, context }): Promise<SupportResult> => {
    if (!EMAIL.test(data.email)) return { ok: false, field: "email", message: "Enter an address we can reply to." };
    if (data.message.length < 10) return { ok: false, field: "message", message: "Tell us a little more." };

    const limit = take(context.ip);
    if (!limit.ok) return { ok: false, message: `Too many requests. Try again in ${limit.retryAfter} seconds.` };
    if (!(await isHuman(data.token, context.ip))) return { ok: false, message: "We couldn't confirm you're human." };

    const { token: _token, ...fields } = data;
    const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
      method: "POST",
      headers: {
        Accept: "application/json",
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
        ...(context.origin ? { Origin: context.origin } : {}),
      },
      body: JSON.stringify(fields),
    }).catch(() => null);
    return response?.ok ? { ok: true } : { ok: false, message: "Your request didn't send. Try again." };
  });
  • Le validateur s'exécute avant le handler et ne fait que normaliser l'entrée. Les erreurs de champ reviennent sous forme de résultat que le formulaire peut afficher à côté du bon input.
  • Les vérifications peu coûteuses d'abord : l'e-mail et la longueur ne coûtent rien, le bucket coûte une lecture dans une map, siteverify coûte un appel réseau.
  • action doit valoir support. Un token émis pour un autre widget avec votre site key est refusé.
  • Le honeypot _gotcha est transmis, pas vérifié ici : un honeypot rempli est donc stocké comme spam, où vous pouvez le relire.
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.

La route et le widget

Le widget est rendu explicitement depuis un petit hook. Une promesse au niveau du module charge le script une seule fois, même quand la route est montée plusieurs fois pendant la navigation côté client, et le token arrive via le callback de Turnstile :

src/lib/use-turnstile.ts
import { useCallback, useEffect, useRef, useState } from "react";

interface Api {
  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<Api> | undefined;

function load() {
  loading ??= new Promise<Api>((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?: Api }).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(sitekey: string, action: string) {
  const ref = useRef<HTMLDivElement>(null);
  const api = useRef<Api | null>(null);
  const widget = useRef<string | null>(null);
  const [token, setToken] = useState("");

  useEffect(() => {
    let live = true;
    load().then((turnstile) => {
      if (!live || !ref.current) return;
      api.current = turnstile;
      widget.current = turnstile.render(ref.current, {
        sitekey,
        action,
        callback: setToken,
        "expired-callback": () => setToken(""),
      });
    });
    return () => {
      live = false;
      if (widget.current) api.current?.remove(widget.current);
      widget.current = null;
    };
  }, [sitekey, action]);

  const reset = useCallback(() => {
    setToken("");
    if (widget.current) api.current?.reset(widget.current);
  }, []);

  return { ref, token, reset };
}
src/routes/support.tsx
import { createFileRoute } from "@tanstack/react-router";
import { useServerFn } from "@tanstack/react-start";
import { type FormEvent, useState } from "react";
import { useTurnstile } from "../lib/use-turnstile";
import { type SupportResult, sendSupportRequest } from "../server/support";

export const Route = createFileRoute("/support")({ component: Support });

function Support() {
  const send = useServerFn(sendSupportRequest);
  const turnstile = useTurnstile(import.meta.env.VITE_TURNSTILE_SITE_KEY, "support");
  const [result, setResult] = useState<SupportResult | null>(null);
  const [pending, setPending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = new FormData(event.currentTarget);
    setPending(true);
    try {
      setResult(
        await send({
          data: {
            email: String(form.get("email") ?? ""),
            topic: String(form.get("topic") ?? ""),
            message: String(form.get("message") ?? ""),
            _gotcha: String(form.get("_gotcha") ?? ""),
            token: turnstile.token,
          },
        }),
      );
    } catch {
      setResult({ ok: false, message: "Couldn't reach the server. Try again." });
    } finally {
      setPending(false);
      turnstile.reset();
    }
  }

  if (result?.ok) return <p role="status">Got it. We'll reply by email within a day.</p>;
  const error = result && !result.ok ? result : null;

  return (
    <form onSubmit={onSubmit} noValidate>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" aria-invalid={error?.field === "email"} />
      <label htmlFor="topic">Topic</label>
      <select id="topic" name="topic" defaultValue="other">
        <option value="billing">Billing</option>
        <option value="bug">Something is broken</option>
        <option value="account">My account</option>
        <option value="other">Something else</option>
      </select>
      <label htmlFor="message">What happened?</label>
      <textarea id="message" name="message" rows={6} aria-invalid={error?.field === "message"} />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hp" />
      <div ref={turnstile.ref} />
      {error ? <p role="alert">{error.message}</p> : null}
      <button disabled={pending || !turnstile.token}>{pending ? "Sending" : "Send request"}</button>
    </form>
  );
}
  • Le bouton reste désactivé jusqu'à ce que Turnstile fournisse un token : personne n'envoie donc de token vide.
  • turnstile.reset() s'exécute après chaque tentative. Un token ne sert qu'une fois et vit 300 secondes : une nouvelle tentative en demande donc un nouveau.
  • Ce formulaire a besoin de JavaScript. Les server functions peuvent recevoir des envois de formulaire simples via leur .url, mais le widget Turnstile a de toute façon besoin d'un navigateur.

En-têtes de sécurité dans src/start.ts

Start n'a pas d'option CSP propre. Un middleware de requête global dans src/start.ts peut en définir une sur chaque réponse. Créer ce fichier désactive le middleware CSRF que Start installe par défaut : rajoutez-le donc d'abord :

src/start.ts
import { createCsrfMiddleware, createMiddleware, createStart } from "@tanstack/react-start";
import { setResponseHeaders } from "@tanstack/react-start/server";

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'",
  "frame-ancestors 'none'",
].join("; ");

const securityHeaders = createMiddleware().server(async ({ next }) => {
  setResponseHeaders(new Headers({ "Content-Security-Policy": policy }));
  return next();
});

export const startInstance = createStart(() => ({
  requestMiddleware: [createCsrfMiddleware({ filter: (ctx) => ctx.handlerType === "serverFn" }), securityHeaders],
}));
  • Le HTML rendu côté serveur par Start contient des scripts inline. Sans configuration de nonce, script-src a besoin de 'unsafe-inline' ; il bloque quand même les scripts de tout hôte que vous n'avez pas listé.
  • Turnstile dessine son widget dans une iframe de challenges.cloudflare.com, que frame-src doit autoriser.
  • Le filter CSRF limite la vérification aux server functions : les chargements de page normaux ne sont donc pas concernés.

Déploiement

HébergeurCLIENT_IP_HEADERLimiteur
Votre propre serveur Node ou conteneurCe que définit votre proxyLa mémoire convient
Vercelx-forwarded-forRedis
Netlifyx-nf-client-connection-ipRedis
Cloudflare Workerscf-connecting-ipBinding de limitation de débit Workers

Vercel documente qu'il écrase x-forwarded-for avec l'adresse du visiteur. Listez chaque hostname de production et de préproduction dans le widget Turnstile avant de déployer.

Laisser Ikiform faire les vérifications

Si vous ne voulez pas gérer vous-même des clés Turnstile et un limiteur, retirez la server function et envoyez depuis le navigateur. Dans onSubmit, remplacez l'appel send par un challenge et un submit, où box est une ref sur un div vide qui remplace le widget :

src/routes/support.tsx
import { challenge, submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

const turnstileToken = await challenge(FORM_ID, { container: box.current ?? undefined });
const result = await submit(FORM_ID, new FormData(event.currentTarget), { turnstileToken });

Réglez le Spam check du formulaire sur Ikiform, choisissez le Rate limit Strict de 2 toutes les 10 minutes par visiteur, et ajoutez votre domaine sous Allowed domains. Ikiform exécute alors le honeypot, Turnstile sur ikiform.com, la limite par IP et le score du contenu, et conserve ce qu'il attrape pendant 30 jours. Votre CSP a besoin de https://ikiform.com dans frame-src et connect-src au lieu de Cloudflare. Le guide d'installation serveur couvre les clés si vous gardez la server function.

Vérifiez que tout fonctionne

  1. Lancez le serveur de dev avec les clés de test et envoyez une demande. Elle apparaît sous Submissions. Sans offre, 50 tests depuis localhost par mois sont gratuits.
  2. Envoyez quatre demandes rapidement. La quatrième renvoie le message « réessayez plus tard » avec un nombre de secondes.
  3. Réglez TURNSTILE_SECRET_KEY sur le secret qui échoue toujours. Chaque demande affiche l'erreur de vérification humaine.
  4. Rejouez l'appel à la server function depuis l'onglet réseau avec curl et sans Origin. Le middleware CSRF le refuse.
  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
Des envois cross-site atteignent les server functions après l'ajout de src/start.tsUn src/start.ts personnalisé remplace le middleware CSRF par défaut. Ajoutez createCsrfMiddleware() à requestMiddleware, comme montré.
Tous les visiteurs partagent un même bucketCLIENT_IP_HEADER nomme un en-tête que votre hébergeur ne définit pas : chaque IP vaut donc unknown. Utilisez l'en-tête du tableau ci-dessus.
La limite semble se réinitialiser au hasard sur VercelChaque instance de fonction a sa propre mémoire. Déplacez le bucket vers Redis.
Le widget s'affiche deux fois en développementLe Strict Mode de React monte les effets deux fois. Le nettoyage retire le premier widget : la production n'en affiche donc qu'un.

Questions

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