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.
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.
- Le navigateur, ou un script, envoie vers l'URL de la server function.
- Le middleware CSRF de Start refuse les envois dont il ne peut pas prouver qu'ils viennent de votre propre origine.
- Le middleware visiteur lit l'IP client et l'en-tête Origin et les transmet comme contexte.
- Le validateur normalise l'entrée avant que le handler ne la voie.
- 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 ».
- Il vérifie le token Turnstile, puis transmet les champs à Ikiform, qui les stocke et note le honeypot.
Mise en place du projet
src/
routes/
support.tsx
lib/
use-turnstile.ts
server/
bucket.ts
visitor.ts
support.ts
start.ts
.envVITE_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 viaimport.meta.env. Les secrets n'ont pas de préfixe : ils n'existent donc que côté serveur, dansprocess.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
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 :
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
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.
actiondoit valoirsupport. Un token émis pour un autre widget avec votre site key est refusé.- Le honeypot
_gotchaest transmis, pas vérifié ici : un honeypot rempli est donc stocké comme spam, où vous pouvez le relire.
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 :
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 };
}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 :
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-srca 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, queframe-srcdoit autoriser. - Le
filterCSRF limite la vérification aux server functions : les chargements de page normaux ne sont donc pas concernés.
Déploiement
| Hébergeur | CLIENT_IP_HEADER | Limiteur |
|---|---|---|
| Votre propre serveur Node ou conteneur | Ce que définit votre proxy | La mémoire convient |
| Vercel | x-forwarded-for | Redis |
| Netlify | x-nf-client-connection-ip | Redis |
| Cloudflare Workers | cf-connecting-ip | Binding 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 :
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
- 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
localhostpar mois sont gratuits. - Envoyez quatre demandes rapidement. La quatrième renvoie le message « réessayez plus tard » avec un nombre de secondes.
- Réglez
TURNSTILE_SECRET_KEYsur le secret qui échoue toujours. Chaque demande affiche l'erreur de vérification humaine. - Rejouez l'appel à la server function depuis l'onglet réseau avec
curlet sansOrigin. Le middleware CSRF le refuse. - 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ème | Que faire |
|---|---|
Des envois cross-site atteignent les server functions après l'ajout de src/start.ts | Un src/start.ts personnalisé remplace le middleware CSRF par défaut. Ajoutez createCsrfMiddleware() à requestMiddleware, comme montré. |
| Tous les visiteurs partagent un même bucket | CLIENT_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 Vercel | Chaque instance de fonction a sa propre mémoire. Déplacez le bucket vers Redis. |
| Le widget s'affiche deux fois en développement | Le Strict Mode de React monte les effets deux fois. Le nettoyage retire le premier widget : la production n'en affiche donc qu'un. |
Questions
Lisez l'en-tête que définit votre hébergeur avec getRequestHeader de @tanstack/react-start/server, idéalement une seule fois dans un middleware de fonction qui la passe au handler comme contexte. Utilisez x-forwarded-for sur Vercel, x-nf-client-connection-ip sur Netlify et cf-connecting-ip sur Cloudflare.
Oui. Start installe un middleware CSRF pour les server functions, sauf si vous définissez src/start.ts, auquel cas vous ajoutez createCsrfMiddleware vous-même. Il bloque les envois cross-site des navigateurs, pas les scripts : les vérifications anti-spam restent donc nécessaires.
Sur un seul serveur Node qui tourne en continu, oui. Sur les hébergeurs serverless, chaque instance a sa propre mémoire : utilisez donc un stockage partagé comme Redis ou le binding de limitation de débit de Cloudflare.
Non, il n'y a pas d'offre gratuite. Les tests sont gratuits : vous pouvez envoyer 50 soumissions de test par mois depuis localhost avant de payer. Pour passer en production, Pro commence à 6 $/mois pour 500 soumissions, ou vous pouvez payer une seule fois 10 000 soumissions pour 49 $ ou 50 000 soumissions pour 149 $, avec des crédits qui n'expirent jamais.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.