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 Astro avec Turnstile

Protégez un formulaire Astro du spam avec une Action : un honeypot, Turnstile vérifié avec les secrets astro:env, une limite par IP et la CSP intégrée d'Astro.

Preet SutharFondateur, Ikiform

Placez les vérifications dans une Astro Action vers laquelle le formulaire envoie sans JavaScript : limitez par context.clientAddress, vérifiez le token Turnstile avec un secret issu de astro:env/server, puis transmettez les champs avec le honeypot. La page de contact doit être rendue à la demande : ajoutez donc un adaptateur et prerender = false sur cette page. Si vous préférez garder tout le site statique, envoyez le formulaire simple vers Ikiform et laissez-le faire les mêmes vérifications.

Ce qui change avec Astro

Astro construit du HTML statique par défaut, et le HTML statique ne peut pas garder de secret. Un secret Turnstile, une clé secrète Ikiform et un compteur de limite de débit ont tous besoin de code qui s'exécute à chaque requête : la première décision sur un site Astro est donc de savoir si la page de contact est rendue à la demande.

Choisir où tournent les vérifications anti-spam sur un site Astropagedecontactà lademandeadaptateur+prerender= falseAstroActionlimite,siteverify,transmissionIkiformclésecrète,stockeformulaireHTMLsimpleniadaptateur,niJavaScriptIkiformhoneypot,Turnstile,limitevos propres clésPOST du formulaireJSONrester statiquePOSTChoisir où tournent les vérifications anti-spam sur un site Astropage decontactà la demandeadaptateur +prerender = falseAstro Actionlimite, siteverify,transmissionIkiformclé secrète, stockeformulaireHTMLsimpleniadaptateur,niJavaScriptIkiformhoneypot,Turnstile,limitevos propres clésPOST du formulaireJSONrester statique
  1. Décidez comment la page de contact est rendue.
  2. Avec vos propres clés Turnstile, la page est rendue à la demande via un adaptateur.
  3. Le formulaire envoie vers une Astro Action, qui limite par IP, vérifie le token et transmet les champs.
  4. Ikiform stocke le message et note le honeypot.
  5. Pour garder le site statique, le formulaire simple envoie directement à Ikiform, qui exécute le honeypot, sa vérification Turnstile gérée et la limite de débit.
Les deux voies arrêtent les mêmes bots. Celle du haut vous donne votre propre widget et votre propre limite ; celle du bas ne livre aucun code serveur.

Astro vérifie aussi par défaut l'origine des envois de formulaire vers les pages à la demande, via security.checkOrigin. Cela empêche la page d'un autre site d'envoyer vers la vôtre. Cela n'arrête pas un script, qui peut envoyer les en-têtes qu'il veut : les vérifications ci-dessous restent donc nécessaires.

Mise en place du projet

Fichiers
src/
  actions/
    index.ts
  lib/
    limit.ts
    turnstile.ts
  pages/
    contact.astro
astro.config.mjs
.env

Ajoutez un adaptateur avec npx astro add vercel, netlify ou cloudflare. Déclarez chaque variable dans le schéma astro:env, pour qu'un secret manquant échoue à l'import et qu'un secret ne puisse jamais être importé dans du code client :

.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
astro.config.mjs
import vercel from "@astrojs/vercel";
import { defineConfig, envField } from "astro/config";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      PUBLIC_TURNSTILE_SITE_KEY: envField.string({ context: "client", access: "public" }),
      TURNSTILE_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      IKIFORM_SECRET_KEY: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_URL: envField.string({ context: "server", access: "secret" }),
      UPSTASH_REDIS_REST_TOKEN: envField.string({ context: "server", access: "secret" }),
    },
  },
  security: {
    csp: {
      scriptDirective: {
        resources: ["'self'", "https://challenges.cloudflare.com"],
      },
      directives: ["frame-src https://challenges.cloudflare.com"],
    },
  },
});
  • access: "secret" tient une valeur hors du bundle. astro:env/server refuse de se charger dans un script client.
  • scriptDirective.resources remplace les valeurs par défaut d'Astro : listez donc de nouveau 'self' à côté de Cloudflare. Astro hache toujours ses propres scripts.
  • La CSP ne s'applique pas sous astro dev. Vérifiez-la avec astro build et astro preview.

Une limite par IP

Dans une Action, context.clientAddress est l'IP du visiteur telle que votre adaptateur la rapporte depuis la plateforme : vous n'analysez donc jamais X-Forwarded-For vous-même. Elle n'existe que sur les routes à la demande et lève une erreur sur les routes pré-rendues. Ce compteur garde une clé Redis par IP et par minute : il fonctionne donc sur plusieurs instances serverless :

src/lib/limit.ts
import { Redis } from "@upstash/redis";
import { UPSTASH_REDIS_REST_TOKEN, UPSTASH_REDIS_REST_URL } from "astro:env/server";

const redis = new Redis({ url: UPSTASH_REDIS_REST_URL, token: UPSTASH_REDIS_REST_TOKEN });

export async function overLimit(ip: string, limit = 5, windowSeconds = 60) {
  const key = `contact:${ip}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
  const [count] = await redis.multi().incr(key).expire(key, windowSeconds).exec<[number, number]>();
  return count > limit;
}

Sur Cloudflare, Astro 7 lit les bindings depuis cloudflare:workers au lieu de Astro.locals.runtime. Un binding de limitation de débit Workers déclaré dans wrangler.jsonc remplace Redis sans appel réseau, au prix d'un compte approximatif par emplacement :

src/lib/limit.ts
import { env } from "cloudflare:workers";

export async function overLimit(ip: string) {
  const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
  return !success;
}

Vérifier le token

src/lib/turnstile.ts
import { TURNSTILE_SECRET_KEY } from "astro:env/server";

export async function isHuman(token: string, ip: string, host: string) {
  const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, 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 };
    if (!verdict.success || verdict.action !== "contact") return false;
    return import.meta.env.DEV || verdict.hostname === host;
  } catch {
    return false;
  }
}
  • Siteverify accepte les corps form-encoded : URLSearchParams n'a donc besoin d'aucun en-tête.
  • Le hostname est comparé à context.url.hostname, l'hôte vers lequel le visiteur a réellement envoyé. Les tokens de test portent un hostname factice : la vérification est donc sautée sous astro dev.
  • Un token est valable 300 secondes et ne sert qu'une fois. Un envoi de formulaire simple recharge la page, et Turnstile en émet un nouveau à chaque fois.

L'action Astro

src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { IKIFORM_SECRET_KEY } from "astro:env/server";
import { z } from "astro/zod";
import { overLimit } from "../lib/limit";
import { isHuman } from "../lib/turnstile";

export const server = {
  sendMessage: defineAction({
    accept: "form",
    input: z.object({
      name: z.string().trim().min(1, "Tell us your name.").max(120),
      email: z.email("Enter an address we can reply to."),
      message: z.string().trim().min(10, "A few words more, please.").max(5000),
      _gotcha: z.string().optional(),
      "cf-turnstile-response": z.string().max(2048).optional(),
    }),
    handler: async (input, context) => {
      const ip = context.clientAddress;
      if (await overLimit(ip)) {
        throw new ActionError({ code: "TOO_MANY_REQUESTS", message: "Too many messages. Try again in a minute." });
      }
      if (!(await isHuman(input["cf-turnstile-response"] ?? "", ip, context.url.hostname))) {
        throw new ActionError({ code: "FORBIDDEN", message: "We couldn't confirm you're human. Try again." });
      }

      const { "cf-turnstile-response": _token, ...fields } = input;
      const origin = context.request.headers.get("origin");
      const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
          Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
          ...(origin ? { Origin: origin } : {}),
        },
        body: JSON.stringify(fields),
      });
      if (!response.ok) throw new ActionError({ code: "BAD_GATEWAY", message: "Your message didn't send. Try again." });
      return { sent: true };
    },
  }),
};
  • Zod s'exécute avant le handler : un envoi malformé ne coûte donc aucun appel Redis. Les erreurs de champ reviennent à la page via isInputError.
  • Le honeypot _gotcha est facultatif dans le schéma et transmis tel quel. Ikiform note un honeypot rempli comme spam : vous pouvez donc quand même le relire.
  • Le token est retiré avant la transmission. Ikiform n'a que faire d'un token que vous avez déjà vérifié.
  • 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.
  • TOO_MANY_REQUESTS correspond à un 429 et FORBIDDEN à un 403.
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 page

src/pages/contact.astro
---
import { actions, isInputError } from "astro:actions";
import { PUBLIC_TURNSTILE_SITE_KEY } from "astro:env/client";

export const prerender = false;

const result = Astro.getActionResult(actions.sendMessage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : {};
---

<main>
  <h1>Contact us</h1>
  {result?.data?.sent ? (
    <p role="status">Thanks. We'll reply by email.</p>
  ) : (
    <form method="POST" action={actions.sendMessage}>
      <label for="name">Name</label>
      <input id="name" name="name" autocomplete="name" required maxlength="120" />
      {fields.name && <p class="field-error">{fields.name[0]}</p>}
      <label for="email">Email</label>
      <input id="email" name="email" type="email" autocomplete="email" required />
      {fields.email && <p class="field-error">{fields.email[0]}</p>}
      <label for="message">Message</label>
      <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
      {fields.message && <p class="field-error">{fields.message[0]}</p>}
      <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 class="cf-turnstile" data-sitekey={PUBLIC_TURNSTILE_SITE_KEY} data-action="contact"></div>
      {result?.error && !isInputError(result.error) && <p role="alert">{result.error.message}</p>}
      <button>Send message</button>
    </form>
  )}
</main>

<script is:inline src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>

Le formulaire fonctionne sans aucun JavaScript côté client. action={actions.sendMessage} envoie vers la même page avec le nom de l'action dans la query string ; Astro exécute l'action et Astro.getActionResult() lit le résultat pendant le rendu. Seule cette page est rendue à la demande. Le reste du site reste statique.

  • Actualiser après un envoi échoué demande de renvoyer le formulaire. La documentation d'Astro suggère le modèle POST, redirection, GET si cela vous gêne, en transportant le résultat dans une session.
  • Le script Turnstile utilise is:inline : Astro n'y touche donc pas. Il doit être chargé depuis l'URL de Cloudflare.
  • Appeler l'action depuis un script client sur une page statique fonctionne aussi, mais vous devez alors appeler turnstile.reset() vous-même après chaque tentative.

Déploiement

HébergeurAdaptateurOù vivent les secretsLimite
Vercel@astrojs/vercelVariables d'environnement du projetRedis
Netlify@astrojs/netlifyVariables d'environnement du siteRedis
Cloudflare@astrojs/cloudflarewrangler secret putBinding de limitation de débit Workers

Ajoutez votre hostname de production au widget Turnstile avant de déployer, et ajoutez aussi les hostnames de préproduction si vous y testez.

Rester statique et laisser Ikiform vérifier

La plupart des sites Astro sont statiques, et ajouter un adaptateur uniquement pour un formulaire de contact a un vrai coût. Sans adaptateur, envoyez le formulaire directement vers votre endpoint Ikiform :

src/components/Contact.astro
---
---

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required minlength="10"></textarea>
  <div class="hp" aria-hidden="true">
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" />
  </div>
  <button>Send message</button>
</form>

Réglez le Spam check du formulaire 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 : la page n'a donc besoin ni de script, ni de clés, ni de changement de CSP. Choisissez le Rate limit Strict, 2 toutes les 10 minutes par visiteur, et ajoutez votre domaine sous Allowed domains. Le spam reçoit la même réponse qu'un vrai message et est conservé 30 jours pour que vous puissiez repêcher un faux positif. Le guide d'installation Astro montre le reste.

Vérifiez que tout fonctionne

  1. Lancez astro 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. Laissez le nom vide après avoir retiré l'attribut required dans les outils de développement. L'erreur de champ s'affiche sous l'input.
  3. Réglez TURNSTILE_SECRET_KEY sur le secret qui échoue toujours et envoyez. La page affiche l'erreur de vérification humaine.
  4. Envoyez six fois en une minute. Le sixième répond 429 avec le message de limite.
  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.
  6. Lancez astro build et astro preview, ouvrez la page et vérifiez que la console n'affiche aucune violation de CSP.

Pièges

ProblèmeQue faire
clientAddress n'est pas disponible sur les pages pré-renduesLa page ou l'action a été exécutée au build. Ajoutez export const prerender = false à la page de contact.
Le résultat de l'action est toujours undefinedLe formulaire a envoyé vers une page pré-rendue. Les form actions ne s'exécutent que sur les pages rendues à la demande.
Le widget est vide en production mais fonctionne en devLa CSP le bloque. scriptDirective.resources doit lister https://challenges.cloudflare.com, et frame-src doit aussi l'autoriser.
403 sans message sur chaque envoi depuis un domaine personnalisésecurity.checkOrigin a vu une origine différente, souvent à cause d'un proxy qui réécrit l'hôte. Assurez-vous que l'en-tête host qui atteint Astro correspond au domaine de la page.
La soumission est dans le tableau de bord mais aucun e-mail ni webhook n'est arrivéElle a été stockée comme spam. Le spam n'envoie rien. Ouvrez-la sous le filtre Spam pour voir les raisons, et marquez-la comme non-spam pour envoyer son e-mail et ses webhooks.

Questions

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