Arrêter le spam des formulaires de contact8 min de lecture
Protection anti-spam d'un formulaire SvelteKit avec Turnstile
Protégez un formulaire SvelteKit du spam : une form action avec getClientAddress, une limite par IP, Turnstile derrière use:enhance, et une CSP qui l'autorise.
Faites les vérifications dans une form action : limitez par getClientAddress(), vérifiez le token Turnstile avec siteverify, puis transmettez les champs avec le honeypot. Rendez le widget avec une action Svelte et réinitialisez-le dans le callback de use:enhance, car chaque token ne sert qu'une fois. Autorisez challenges.cloudflare.com dans l'option csp du plugin sveltekit(), et sur adapter-node, indiquez à SvelteKit quel en-tête du proxy contient la vraie IP.
Ce qui change avec SvelteKit
SvelteKit vous donne directement l'IP du visiteur : getClientAddress() sur l'événement de requête. L'origine de cette valeur dépend de l'adaptateur, et sur adapter-node la valeur par défaut est fausse derrière n'importe quel proxy. Réglez ce point et le reste des vérifications trouve naturellement sa place dans une form action.
- La form action demande l'adresse du client à l'événement de requête.
- SvelteKit délègue la réponse à l'adaptateur avec lequel vous déployez.
- adapter-node utilise l'adresse distante du socket, sauf si vous définissez ADDRESS_HEADER et XFF_DEPTH.
- Les adaptateurs Vercel, Netlify et Cloudflare lisent l'adresse fournie par leur plateforme : il n'y a rien à configurer.
L'autre détail de SvelteKit est use:enhance. Il envoie sans rechargement de page, ce qui est plus agréable pour les personnes mais signifie que Turnstile ne se réaffiche jamais tout seul. Le widget doit être réinitialisé à la main après chaque réponse.
Mise en place du projet
src/
env.ts
lib/
turnstile.ts
server/
spam.ts
routes/
contact/
+page.server.ts
+page.svelte
vite.config.ts
.envPUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
UPSTASH_REDIS_REST_URL=https://your-db.upstash.io
UPSTASH_REDIS_REST_TOKEN=your_tokenimport { defineEnvVars } from "@sveltejs/kit/env";
export const variables = defineEnvVars({
PUBLIC_TURNSTILE_SITE_KEY: { public: true },
TURNSTILE_SECRET_KEY: {},
IKIFORM_SECRET_KEY: {},
UPSTASH_REDIS_REST_URL: {},
UPSTASH_REDIS_REST_TOKEN: {},
});- SvelteKit 3 lit les variables que vous déclarez dans
src/env.ts. Seule la site key apublic: true: c'est donc la seule que$app/env/publicpeut transmettre au navigateur. Rien de ce qui se trouve dans un dossierserver, commesrc/lib/server, ne peut être importé par du code client ; SvelteKit fait échouer le build si vous essayez. - Les variables déclarées sont lues au démarrage de l'application sauf si vous définissez
static: true: une clé renouvelée demande donc un redémarrage, pas un rebuild. Les modules$env/*fonctionnent encore mais sont dépréciés dans SvelteKit 3. - Les clés de test de Cloudflare passent sur n'importe quel hôte, y compris
localhost.
import adapter from "@sveltejs/adapter-node";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter(),
csp: {
mode: "auto",
directives: {
"script-src": ["self", "https://challenges.cloudflare.com"],
"frame-src": ["https://challenges.cloudflare.com"],
"object-src": ["none"],
"base-uri": ["self"],
},
},
}),
],
});SvelteKit 3 prend ses options dans vite.config.ts, passées au plugin sveltekit() ; svelte.config.js n'est plus lu. Avec mode: "auto", SvelteKit ajoute des nonces à ses propres scripts inline sur les pages rendues côté serveur et des hashes sur les pages pré-rendues : la politique n'a donc pas besoin de 'unsafe-inline'. Turnstile se charge depuis son hôte, que script-src autorise, et dessine une iframe que frame-src autorise.
Les helpers côté serveur uniquement
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { dev } from "$app/env";
import { TURNSTILE_SECRET_KEY, UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "$app/env/private";
const limiter = new Ratelimit({
redis: new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN }),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function secondsToWait(ip: string) {
const { success, reset } = await limiter.limit(ip);
return success ? 0 : Math.max(1, Math.ceil((reset - Date.now()) / 1000));
}
export async function isHuman(token: string, ip: string, host: string) {
if (!token || token.length > 2048) return false;
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: TURNSTILE_SECRET_KEY, response: token, remoteip: ip }),
});
const verdict = (await response.json()) as { success: boolean; hostname?: string; action?: string };
return verdict.success && verdict.action === "contact" && (dev || verdict.hostname === host);
} catch {
return false;
}
}- La limite est une fenêtre glissante dans Redis, partagée par toutes les instances du serveur, cinq messages par minute par IP.
resetrevient en millisecondes et devient un nombre entier de secondes à attendre. devde$app/envsaute la vérification du hostname en local, car les tokens de test portent un hostname factice.- Siteverify répond
timeout-or-duplicatepour un token de plus de 300 secondes ou déjà utilisé : c'est pourquoi la page réinitialise le widget.
La form action
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import { isHuman, secondsToWait } from "#lib/server/spam.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress, url }) => {
const ip = getClientAddress();
const wait = await secondsToWait(ip);
if (wait) return fail(429, { wait, error: `Too many messages. Try again in ${wait} seconds.` });
const form = await request.formData();
const values = {
name: String(form.get("name") ?? "").trim(),
email: String(form.get("email") ?? "").trim(),
message: String(form.get("message") ?? "").trim(),
};
if (values.message.length < 10) return fail(400, { values, error: "A few words more, please." });
if (!(await isHuman(String(form.get("cf-turnstile-response") ?? ""), ip, url.hostname))) {
return fail(403, { values, error: "We couldn't confirm you're human. Try again." });
}
form.delete("cf-turnstile-response");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: form,
}).catch(() => null);
if (!response?.ok) return fail(502, { values, error: "Your message didn't send. Try again." });
return { sent: true };
},
} satisfies Actions;- La limite s'exécute avant l'analyse du corps.
fail(429)garde le visiteur sur la page avec le message. - Chaque autre échec renvoie
values: les inputs se remplissent donc à nouveau après une erreur. - Le
FormDatad'origine est transmis, honeypot compris, sans le token. Ikiform note un honeypot rempli et stocke le message comme spam. - L'
Origindu visiteur est transmis, car un fetch côté serveur n'en envoie aucun et Ikiform le lit pour reconnaître les tests gratuits depuislocalhost.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Le widget sous forme d'action Svelte
const SCRIPT = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
interface Api {
render(container: HTMLElement, options: { sitekey: string; action: string }): 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 = SCRIPT;
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 turnstile(node: HTMLElement, sitekey: string) {
let api: Api | undefined;
load().then((loaded) => {
api = loaded;
node.dataset.widgetId = loaded.render(node, { sitekey, action: "contact" });
});
return {
destroy() {
if (node.dataset.widgetId) api?.remove(node.dataset.widgetId);
},
};
}
export async function resetTurnstile(node: HTMLElement | undefined) {
const id = node?.dataset.widgetId;
if (id) (await load()).reset(id);
}Une action Svelte est l'endroit naturel pour un widget tiers : elle s'exécute au montage de l'élément, et destroy s'exécute à son retrait. L'ID du widget est stocké sur l'élément : la page peut donc réinitialiser précisément ce widget.
La page
<script lang="ts">
import { enhance } from "$app/forms";
import { PUBLIC_TURNSTILE_SITE_KEY } from "$app/env/public";
import { resetTurnstile, turnstile } from "#lib/turnstile.js";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let box = $state<HTMLDivElement>();
let sending = $state(false);
</script>
{#if form?.sent}
<p role="status">Thanks. We'll reply by email.</p>
{:else}
<form
method="POST"
use:enhance={() => {
sending = true;
return async ({ update }) => {
await update({ reset: false });
sending = false;
await resetTurnstile(box);
};
}}
>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" required maxlength="120" value={form?.values?.name ?? ""} />
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required value={form?.values?.email ?? ""} />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required value={form?.values?.message ?? ""}></textarea>
<div class="hp" aria-hidden="true">
<label for="hp-field">Leave this empty</label>
<input id="hp-field" name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
</div>
<div bind:this={box} use:turnstile={PUBLIC_TURNSTILE_SITE_KEY}></div>
{#if form?.error}
<p role="alert">{form.error}</p>
{/if}
<button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
</form>
{/if}update({ reset: false })applique le résultat et garde ce que le visiteur a tapé. Le comportement par défaut viderait le formulaire, comme le fait un navigateur.resetTurnstiles'exécute après chaque réponse, succès ou non. Le token utilisé disparaît et un nouveau est prêt.- Sans JavaScript, le formulaire est quand même envoyé et l'action s'exécute quand même, mais aucun widget n'est rendu : siteverify le refuse donc avec un message clair.
- Une page avec des form actions ne peut pas être pré-rendue : cette route est donc toujours rendue côté serveur. Les autres pages peuvent rester pré-rendues.
- La classe
.hpmasque le honeypot hors écran, comme dans le guide du honeypot.
Tester en local
Sous vite dev, aucun adaptateur n'est placé devant l'application : getClientAddress() renvoie donc votre adresse de loopback et tous les onglets locaux partagent un même compteur. Cela suffit pour vérifier le message, pas pour vérifier la configuration du proxy. Pour la tester, construisez avec adapter-node et exécutez le résultat avec les mêmes variables qu'en production :
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node buildEnvoyez quelques fois avec différentes valeurs de X-Forwarded-For. Avec XFF_DEPTH=1, SvelteKit lit l'adresse la plus à droite, celle qu'ajouterait votre proxy : une valeur inventée par le client à gauche ne change donc rien.
Déploiement
Sur votre propre serveur avec adapter-node derrière un proxy, indiquez à SvelteKit où se trouve la vraie IP :
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1La documentation de SvelteKit avertit que les en-têtes sont faciles à usurper et que lire l'adresse la plus à gauche de X-Forwarded-For est l'erreur classique. XFF_DEPTH compte les proxys de confiance depuis la droite : réglez-le donc sur le nombre de proxys que vous exploitez, et assurez-vous que l'application n'est pas joignable en les contournant. SvelteKit 3 a abandonné la variable ORIGIN d'adapter-node. Si votre proxy ne transmet pas l'hôte d'origine, définissez paths.origin dans les options de sveltekit() pour que la vérification de formulaire de SvelteKit connaisse votre URL publique.
| Adaptateur | IP client | Secrets |
|---|---|---|
adapter-node | ADDRESS_HEADER et XFF_DEPTH | Environnement du processus |
adapter-vercel | Automatique | Variables d'environnement du projet |
adapter-netlify | Automatique | Variables d'environnement du site |
adapter-cloudflare | Automatique | wrangler secret put |
Laisser Ikiform faire les vérifications
Pour ne rien gérer de tout cela vous-même, supprimez +page.server.ts et faites pointer l'action du formulaire vers votre endpoint Ikiform, sans use:enhance. La page peut alors être pré-rendue comme le reste d'un site statique.
Réglez le Spam check du formulaire sur Ikiform : un envoi sans token reçoit ainsi 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. Ikiform conserve le spam attrapé pendant 30 jours : un faux positif se restaure donc d'un clic. Le guide d'installation SvelteKit couvre l'endpoint et les clés.
Vérifiez que tout fonctionne
- Lancez
vite devavec les clés de test et envoyez un message. Il apparaît sous Submissions. Un espace de travail sans offre accepte 50 tests depuislocalhostpar mois. - Envoyez un second message sans recharger. Il réussit, ce qui montre que la réinitialisation du widget a fonctionné.
- Envoyez six messages en une minute. Le sixième indique combien de secondes attendre.
- Derrière votre proxy, journalisez
getClientAddress()depuis deux réseaux. Vous voyez deux adresses différentes, pas celle de votre proxy. - 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 |
|---|---|
Tous les visiteurs sont limités ensemble sur adapter-node | getClientAddress() renvoie l'adresse de votre proxy. Définissez ADDRESS_HEADER et XFF_DEPTH. |
| Le deuxième envoi échoue toujours à la vérification humaine | Le widget a gardé son token déjà utilisé. Appelez resetTurnstile dans le callback de use:enhance après update. |
| Le build échoue parce qu'une page avec des actions est pré-rendue | La route de contact a hérité de prerender = true d'un layout. Les pages avec des actions ne peuvent pas être pré-rendues ; définissez export const prerender = false dans son +page.server.ts. |
| La zone du widget est vide et la console signale une violation de CSP | script-src ou frame-src ne contient pas https://challenges.cloudflare.com dans csp.directives de vite.config.ts. |
Questions
Appelez getClientAddress() sur l'événement de requête dans une form action, une fonction load ou un hook. L'adaptateur la fournit. Sur adapter-node derrière un proxy, définissez ADDRESS_HEADER et XFF_DEPTH, sinon vous obtenez l'adresse du proxy.
use:enhance ne recharge pas la page : le widget garde donc le token déjà utilisé. Réinitialisez le widget dans le callback d'enhance après chaque réponse.
Oui. Ajoutez https://challenges.cloudflare.com à script-src et frame-src dans csp.directives, que SvelteKit 3 prend dans les options du plugin sveltekit() de vite.config.ts. Avec le mode auto, SvelteKit ajoute des nonces ou des hashes pour ses propres scripts.
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.