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 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.

Preet SutharFondateur, Ikiform

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.

Où getClientAddress() obtient l'IP du visiteur selon l'adaptateurform actionappellegetClientAddress()votre adaptateuradapter-nodeadresse du socket, ouADDRESS_HEADERadapter-verceldepuis l'edge de Verceladapter-netlifydepuis l'edge de Netlifyadapter-cloudflaredepuis cf-connecting-ipOù getClientAddress() obtient l'IP du visiteur selon l'adaptateurform actionappellegetClientAddress()votreadaptateuradapter-nodeadresse du socket,ouADDRESS_HEADERadapter-verceldepuis l'edge deVerceladapter-netlifydepuis l'edge deNetlifyadapter-cloudflaredepuiscf-connecting-ip
  1. La form action demande l'adresse du client à l'événement de requête.
  2. SvelteKit délègue la réponse à l'adaptateur avec lequel vous déployez.
  3. adapter-node utilise l'adresse distante du socket, sauf si vous définissez ADDRESS_HEADER et XFF_DEPTH.
  4. Les adaptateurs Vercel, Netlify et Cloudflare lisent l'adresse fournie par leur plateforme : il n'y a rien à configurer.
Derrière nginx ou un load balancer, adapter-node voit l'adresse du proxy pour chaque visiteur tant que vous ne définissez pas ADDRESS_HEADER, et une adresse partagée transforme une limite par IP en limite pour tout le site.

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

Fichiers
src/
  env.ts
  lib/
    turnstile.ts
    server/
      spam.ts
  routes/
    contact/
      +page.server.ts
      +page.svelte
vite.config.ts
.env
.env
PUBLIC_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_token
src/env.ts
import { 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 a public: true : c'est donc la seule que $app/env/public peut transmettre au navigateur. Rien de ce qui se trouve dans un dossier server, comme src/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.
vite.config.ts
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

src/lib/server/spam.ts
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. reset revient en millisecondes et devient un nombre entier de secondes à attendre.
  • dev de $app/env saute la vérification du hostname en local, car les tokens de test portent un hostname factice.
  • Siteverify répond timeout-or-duplicate pour un token de plus de 300 secondes ou déjà utilisé : c'est pourquoi la page réinitialise le widget.

La form action

src/routes/contact/+page.server.ts
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 FormData d'origine est transmis, honeypot compris, sans le token. Ikiform note un honeypot rempli et stocke le message comme spam.
  • 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.
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 sous forme d'action Svelte

src/lib/turnstile.ts
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

src/routes/contact/+page.svelte
<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.
  • resetTurnstile s'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 .hp masque 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 :

Terminal
npm run build
ADDRESS_HEADER=X-Forwarded-For XFF_DEPTH=1 node build

Envoyez 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 :

Environnement
ADDRESS_HEADER=X-Forwarded-For
XFF_DEPTH=1

La 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.

AdaptateurIP clientSecrets
adapter-nodeADDRESS_HEADER et XFF_DEPTHEnvironnement du processus
adapter-vercelAutomatiqueVariables d'environnement du projet
adapter-netlifyAutomatiqueVariables d'environnement du site
adapter-cloudflareAutomatiquewrangler 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

  1. Lancez vite 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 un second message sans recharger. Il réussit, ce qui montre que la réinitialisation du widget a fonctionné.
  3. Envoyez six messages en une minute. Le sixième indique combien de secondes attendre.
  4. Derrière votre proxy, journalisez getClientAddress() depuis deux réseaux. Vous voyez deux adresses différentes, pas celle de votre proxy.
  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
Tous les visiteurs sont limités ensemble sur adapter-nodegetClientAddress() renvoie l'adresse de votre proxy. Définissez ADDRESS_HEADER et XFF_DEPTH.
Le deuxième envoi échoue toujours à la vérification humaineLe 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é-rendueLa 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 CSPscript-src ou frame-src ne contient pas https://challenges.cloudflare.com dans csp.directives de vite.config.ts.

Questions

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