Arrêter le spam des formulaires de contact

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

Comment arrêter le spam sur le formulaire de contact d'un site statique

Arrêtez le spam d'un formulaire de site statique, même sans secret ni compteur, avec un partial honeypot, un script sûr et des vérifications côté serveur.

Preet SutharFondateur, Ikiform

Un site statique n'a pas de serveur : rien sur la page ne peut donc garder une clé secrète, vérifier un token de captcha ou compter les envois par visiteur. Ces vérifications doivent tourner ailleurs : une fonction chez votre hébergeur, ou un backend de formulaires vers lequel votre formulaire envoie. Dans les deux cas, placez un honeypot accessible dans le formulaire, et gardez-le fonctionnel sans JavaScript.

Ce qui change sur un site statique

Sur un site statique, chaque octet déployé est public. Tout ce qui est dans votre HTML ou votre JavaScript peut être lu et contourné par un bot : les vérifications anti-spam côté client sont donc des conseils, pas des contraintes. Les trois vérifications qui comptent le plus ont toutes besoin d'un serveur :

  • Vérifier un token de captcha demande une clé secrète, qui ne peut pas vivre dans un fichier public.
  • Compter les envois par visiteur demande un stockage qui survit d'une requête à l'autre.
  • Lire un honeypot demande du code qui s'exécute après l'arrivée de l'envoi, hors de portée du bot.

Les hébergeurs statiques n'acceptent pas non plus les envois vers vos pages. Vercel et GitHub Pages répondent à un POST de formulaire vers une page statique par 405 Method Not Allowed : le formulaire a donc besoin d'un endpoint avant même que la question du spam ne se pose.

Trois façons de brancher un formulaire de site statique, et où tournent les vérifications anti-spam dans chacuneLa page seuleAvec une fonction chez l'hébergeurAvec un backend de formulairespage statiqueenvoi vers elle-mêmehébergeuraucun code ne tourne405, ou perdupage statiquefonctionsecret, honeypot, vérif.du tokenvotre e-mail oustockagepage statiquebackend deformulaireshoneypot, score, limite,Turnstileboîte de réceptionet webhooksTrois façons de brancher un formulaire de site statique, et où tournent les vérifications anti-spam dans chacuneLa page seuleAvec une fonction chez l'hébergeurAvec un backend de formulairespage statiqueenvoi vers elle-mêmehébergeuraucun code ne tourne405, ou perdupage statiquefonctionsecret, honeypot, vérif. du tokenvotre e-mail ou stockagepage statiquebackend de formulaireshoneypot, score, limite,Turnstileboîte de réception etwebhooks
  1. Une page statique qui envoie vers elle-même atteint un hébergeur qui n'exécute aucun code : l'envoi échoue avec 405 ou se perd.
  2. Avec une fonction chez l'hébergeur, la page envoie vers une fonction qui détient le secret, lit le honeypot et vérifie le token, puis transmet le message.
  3. Avec un backend de formulaires, la page envoie directement au backend, qui exécute le honeypot, le score, la limite de débit et la vérification humaine avant de vous envoyer l'e-mail.
Dans les deux lignes qui fonctionnent, les vérifications tournent après que l'envoi a quitté le navigateur. C'est le seul endroit où un bot ne peut pas les contourner.

Vos options

OptionVérifications anti-spamCompromis
Netlify FormsAkismet sur chaque envoi, plus un champ netlify-honeypot et reCAPTCHA v2 si vous les ajoutezUniquement sur Netlify. Changer d'hébergeur oblige à reconstruire le formulaire. Voir les alternatives à Netlify Forms
Une fonction chez votre hébergeurCe que vous écrivez : honeypot, vérification de token, limite de débit avec un stockageVous l'écrivez et la maintenez, et il vous faut un service d'e-mail ou de stockage derrière
Un backend de formulaires, comme IkiformHoneypot, score, limites de débit, domaines autorisés et vérification humaine, sans codeUn service de plus, facturé à la soumission

Le formulaire, en partial réutilisable

Quel que soit l'endpoint choisi, écrivez le formulaire une seule fois sous forme de partial ou d'include, pour que chaque page utilise le même honeypot. La version ci-dessous envoie vers Ikiform. Pour utiliser une fonction, changez action pour le chemin de la fonction et retirez _redirect.

<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST" class="contact">
  <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></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>
  <input type="hidden" name="_redirect" value="{{ "thanks/" | absURL }}" />
  <p role="status"></p>
  <button>Send message</button>
</form>
assets/contact.css
<style>
.hp {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
</style>
  • Le honeypot est placé hors écran, hors de l'ordre de tabulation et masqué aux lecteurs d'écran. Le guide du honeypot explique chaque attribut.
  • _redirect envoie les gens vers votre page de remerciement. Ikiform ne le suit que pour une adresse https: sur l'un des Allowed domains du formulaire.
  • Les guides Hugo, Jekyll et Eleventy couvrent la mise en place, les shortcodes et GitHub Pages.

L'améliorer sans le casser

Un script peut garder les visiteurs sur la page, mais il ne doit jamais être le seul moyen pour le formulaire de fonctionner. Celui-ci envoie avec fetch et se rabat sur un envoi normal du navigateur dans deux cas : quand l'appel réseau échoue, et quand la vérification humaine d'Ikiform demande un token avec challenge_required. Un envoi normal reçoit alors la page de vérification d'Ikiform : vous pouvez donc activer la vérification sans changer le script.

contact-form.html
<script>
  document.querySelectorAll("form.contact").forEach((form) => {
    const status = form.querySelector("[role=status]");
    const button = form.querySelector("button");

    form.addEventListener("submit", async (event) => {
      event.preventDefault();
      button.disabled = true;
      try {
        const response = await fetch(form.action, {
          method: "POST",
          headers: { Accept: "application/json" },
          body: new FormData(form),
        });
        const result = await response.json();
        if (result.ok) {
          form.replaceChildren("Thanks. We'll reply by email.");
          return;
        }
        if (result.error.code === "challenge_required") {
          form.submit();
          return;
        }
        const wait = response.headers.get("Retry-After");
        if (response.status === 429 && wait) {
          status.textContent = `Too many messages. Try again in ${wait} seconds.`;
        } else {
          status.textContent = result.error.message;
        }
      } catch {
        form.submit();
        return;
      }
      button.disabled = false;
    });
  });
</script>
  • form.submit() envoie le formulaire sans redéclencher l'événement submit : le repli ne peut donc pas boucler.
  • Un 429 indique combien de temps attendre d'après Retry-After, qu'Ikiform expose au code du navigateur.
  • La ligne de succès remplace le formulaire : personne ne l'envoie donc deux fois par accident.

Si vous écrivez une fonction chez l'hébergeur

Netlify, Cloudflare Pages et Vercel exécutent tous de petites fonctions à côté des fichiers statiques. Une fonction est votre propre backend : c'est donc elle qui porte les vérifications anti-spam. Cette Netlify Function lit le honeypot, écarte les messages bourrés de liens, vérifie un token Turnstile et vous envoie un e-mail via Resend. Chaque envoi attrapé reçoit la même redirection de remerciement qu'un vrai.

netlify/functions/contact.mts
import type { Config, Context } from "@netlify/functions";

const LINK = /https?:\/\/|www\./gi;

export default async (req: Request, context: Context) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
  const form = await req.formData();
  const email = String(form.get("email") ?? "");
  const message = String(form.get("message") ?? "");
  const thanks = Response.redirect(new URL("/thanks/", req.url), 303);

  if (String(form.get("_gotcha") ?? "").trim()) return thanks;
  if ((message.match(LINK)?.length ?? 0) >= 3) return thanks;

  const check = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
    method: "POST",
    body: new URLSearchParams({
      secret: process.env.TURNSTILE_SECRET_KEY ?? "",
      response: String(form.get("cf-turnstile-response") ?? ""),
      remoteip: context.ip,
    }),
  });
  const verdict = (await check.json()) as { success: boolean };
  if (!verdict.success) return Response.redirect(new URL("/contact/?error=check", req.url), 303);

  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.RESEND_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Contact form <form@example.com>",
      to: "you@example.com",
      reply_to: email,
      subject: "New message from the website",
      text: message,
    }),
  });
  return thanks;
};

export const config: Config = { path: "/api/contact" };
  • context.ip est l'IP du visiteur, que siteverify peut utiliser comme remoteip.
  • Définissez TURNSTILE_SECRET_KEY et RESEND_API_KEY dans les variables d'environnement du site, jamais dans le dépôt.
  • Elle jette les envois attrapés au lieu de les stocker : un vrai message attrapé par erreur est donc perdu. Journalisez-les au moins.
  • Elle n'a pas de limite de débit, puisqu'une fonction n'a pas de mémoire d'un appel à l'autre. Le guide sur la limitation de débit en ajoute une avec Redis.

Ce que fait Ikiform pour un formulaire statique

Avec un formulaire qui envoie directement vers Ikiform, le travail côté serveur se fait là-bas. Le honeypot est _gotcha, le score du contenu et les limites de débit par visiteur s'exécutent sur chaque envoi, et Allowed domains marque comme spam les envois venant d'autres sites. Réglez Spam check sur Ikiform pour Cloudflare Turnstile sans clés : l'envoi HTML simple l'exécute sur une page de vérification, et le script ci-dessus se rabat sur cette page. Un envoi jugé comme spam reçoit la même réponse de succès qu'un vrai : un bot n'apprend donc rien. Il est stocké avec le statut spam, n'envoie ni e-mail de notification ni webhook, ne compte pas dans votre consommation, et est supprimé au bout de 30 jours. Chaque lundi, un espace de travail qui a bloqué du spam reçoit un e-mail récapitulatif avec le total, les formulaires les plus sollicités et les principales raisons.

Listez sous Allowed domains chaque hôte où le formulaire est publié : votre domaine, plus l'adresse propre de l'hébergeur pendant vos tests, comme username.github.io. Les deploy previews de Netlify et les URL de préproduction de Vercel ne sont pas des sous-domaines de l'adresse de votre site principal ; ajoutez l'hôte exact de la préproduction pendant que vous la testez.

Vérifiez que tout fonctionne

  1. Désactivez JavaScript et envoyez. Le formulaire est quand même envoyé et vous arrivez sur votre page de remerciement.
  2. Activez JavaScript et envoyez. Le formulaire est remplacé par la ligne de remerciement.
  3. 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.
  4. Réglez Spam check sur Ikiform et envoyez avec le script actif. La page se rabat sur un envoi normal et affiche la page de vérification.
  5. Rien n'est arrivé ? Passez le filtre de statut du tableau de bord sur Spam. Le spam reçoit la même réponse de succès qu'une vraie soumission.

Pièges

  • 403 origin_not_allowed, ou chaque envoi atterrit dans le spam. Le domaine de la page n'est pas dans les Allowed domains du formulaire. Ajoutez-le, ainsi que localhost pour les tests en local. Les sous-domaines d'un domaine listé sont autorisés.
  • HTML minifié. Certaines étapes de build retirent les attributs qu'elles jugent vides ou inutilisés. Vérifiez que la page construite a toujours aria-hidden et tabindex sur le honeypot.
  • Pages de remerciement en cache. Si la page de remerciement a la même URL pour le spam et les vrais envois, ce qui doit être le cas, n'y mettez jamais rien qui suppose que l'envoi est réel.
  • Clés dans le dépôt. Un secret commité dans le dépôt public d'un site statique est public. Renouvelez-le si cela s'est déjà produit.

Questions

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