Arrêter le spam des formulaires de contact7 min de lecture
Comment limiter le débit des envois de formulaire
Limitez les envois de formulaire par visiteur avec un stockage partagé, répondez 429 avec Retry-After, et évitez de bloquer les bureaux qui partagent une IP.
Comptez les envois par IP de visiteur dans un stockage que partagent toutes les instances du serveur, comme Redis, autorisez une petite rafale, et répondez 429 avec un en-tête Retry-After quand le compteur est épuisé. Un compteur en mémoire ne fonctionne que sur un seul serveur qui tourne en continu, jamais sur les hébergeurs serverless. Fixez la limite d'après l'usage réel du formulaire, et laissez de la marge pour les bureaux et les événements où beaucoup de personnes partagent une IP.
Ce qui distingue les limites de débit
Une limite de débit ne distingue pas le spam du courrier légitime. Elle arrête le volume : un script qui envoie en boucle, un bot qui passe en revue votre formulaire, ou une personne qui martèle le bouton. C'est donc la couche la moins coûteuse, puisqu'elle s'exécute avant la lecture du corps, et la plus grossière, puisqu'elle ne distingue pas un attaquant d'un bureau animé. Trois choses décident si elle aide ou nuit : où vit le compteur, ce par quoi vous comptez, et la vitesse à laquelle le quota revient.
- Un visiteur envoie 3 fois en 10 secondes. Tous les envois sont acceptés.
- Le suivant est refusé avec 429 et un en-tête Retry-After qui indique combien de temps attendre.
- Ensuite, un envoi de plus est autorisé toutes les 12 secondes.
- Après une minute calme complète, tout le quota est revenu.
Où vit le compteur
| Stockage | Fonctionne sur | Points de vigilance |
|---|---|---|
Une Map en mémoire | Un seul serveur qui tourne en continu | Perdu au redémarrage. Chaque instance serverless a sa propre map : la limite réelle est donc inconnue |
| Redis, comme Upstash | Tout, y compris serverless et edge | Un appel réseau par envoi. Choisissez une région proche de votre serveur |
| Binding de limitation de débit de Cloudflare Workers | Workers | Approximatif et compté par emplacement Cloudflare, avec des périodes de 10 ou 60 secondes uniquement |
Un reverse proxy, comme limit_req de nginx | Serveurs derrière votre propre proxy | Compte par proxy. Nécessite la vraie IP du client |
L'algorithme compte moins que le stockage, mais il façonne ce que ressentent les visiteurs. Une fenêtre fixe se réinitialise à chaque minute : un script peut donc envoyer un quota complet à la fin d'une minute et un autre au début de la suivante. Une fenêtre glissante lisse ce bord en pondérant la fenêtre précédente. Un token bucket, ou l'algorithme similaire qu'utilise Ikiform, recharge un envoi à la fois, ce qui permet à une personne qui a fait une faute de frappe de renvoyer quelques secondes plus tard sans ouvrir la porte à une rafale. Pour un formulaire de contact, n'importe lequel convient si les chiffres correspondent à l'usage réel.
Les exemples ci-dessous utilisent @upstash/ratelimit d'Upstash, car il fonctionne de la même façon dans chaque framework et sur les hébergeurs serverless. Définissez UPSTASH_REDIS_REST_URL et UPSTASH_REDIS_REST_TOKEN, et enregistrez le helper une fois :
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const contact = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "contact",
});
export async function allow(ip: string | null | undefined) {
const { success, reset } = await contact.limit(ip || "no-ip");
return { ok: success, retryAfter: Math.max(1, Math.ceil((reset - Date.now()) / 1000)) };
}reset est un temps Unix en millisecondes : le helper le convertit donc en secondes entières pour Retry-After. Les envois sans IP partagent un même compteur, ce qui empêche un en-tête manquant de devenir un laissez-passer.
L'appliquer dans votre framework
Un formulaire HTML simple n'a pas de serveur pour compter quoi que ce soit. Envoyez-le à Ikiform et choisissez un préréglage sur le formulaire ; un visiteur au-delà de la limite voit une courte page de ralentissement au lieu d'une erreur.
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="POST">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button>Send</button>
</form>Vérifiez la limite en haut de la server action, avant tout autre travail.
"use server";
import { headers } from "next/headers";
import { allow } from "@/lib/limit";
export async function sendMessage(_previous: { message: string }, formData: FormData) {
const forwarded = (await headers()).get("x-forwarded-for");
const limit = await allow(forwarded?.split(",")[0]?.trim());
if (!limit.ok) return { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
await saveMessage(Object.fromEntries(formData));
return { message: "Thanks. We'll reply by email." };
}context.clientAddress donne l'IP du visiteur sur une page à la demande. TOO_MANY_REQUESTS correspond à un 429.
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { allow } from "../lib/limit";
export const server = {
sendMessage: defineAction({
accept: "form",
input: z.object({ email: z.email(), message: z.string().min(1) }),
handler: async (input, context) => {
const limit = await allow(context.clientAddress);
if (!limit.ok) {
throw new ActionError({ code: "TOO_MANY_REQUESTS", message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(input);
return { sent: true };
},
}),
};Définissez le statut et Retry-After depuis la fonction serveur, pour que les appels fetch puissent les lire.
import { createServerFn } from "@tanstack/react-start";
import { getRequestHeader, setResponseHeaders, setResponseStatus } from "@tanstack/react-start/server";
import { allow } from "../lib/limit";
export const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return { email: String(data.get("email") ?? ""), message: String(data.get("message") ?? "") };
})
.handler(async ({ data }) => {
const limit = await allow(getRequestHeader("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
setResponseStatus(429);
setResponseHeaders(new Headers({ "Retry-After": String(limit.retryAfter) }));
return { ok: false, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` };
}
await saveMessage(data);
return { ok: true, message: "Thanks. We'll reply by email." };
});data() permet à l'action de renvoyer un 429 avec des en-têtes en plus du message pour le formulaire.
import { data } from "react-router";
import { allow } from "../lib/limit";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const limit = await allow(request.headers.get("x-forwarded-for")?.split(",")[0]?.trim());
if (!limit.ok) {
return data(
{ message: `Too many messages. Try again in ${limit.retryAfter} seconds.` },
{ status: 429, headers: { "Retry-After": String(limit.retryAfter) } },
);
}
await saveMessage(Object.fromEntries(await request.formData()));
return { message: "Thanks. We'll reply by email." };
}getClientAddress() lit l'IP depuis votre adaptateur. fail(429) garde le formulaire sur la page avec le message. Enregistrez le helper dans src/lib/limit.ts ; SvelteKit 3 l'importe via l'alias #lib de package.json.
import { fail } from "@sveltejs/kit";
import { allow } from "#lib/limit.js";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, getClientAddress }) => {
const limit = await allow(getClientAddress());
if (!limit.ok) return fail(429, { message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
await saveMessage(Object.fromEntries(await request.formData()));
return { sent: true };
},
} satisfies Actions;getRequestIP avec xForwardedFor lit l'IP du client derrière un proxy. Enregistrez le helper dans server/utils pour qu'il soit importé automatiquement.
export default defineEventHandler(async (event) => {
const limit = await allow(getRequestIP(event, { xForwardedFor: true }));
if (!limit.ok) {
setResponseHeader(event, "Retry-After", limit.retryAfter);
throw createError({ status: 429, message: `Too many messages. Try again in ${limit.retryAfter} seconds.` });
}
await saveMessage(await readBody(event));
return { sent: true };
});Une application monopage ne peut pas imposer de limite, mais elle doit en respecter une. Lisez Retry-After et affichez un compte à rebours au lieu de laisser les gens appuyer de nouveau sur le bouton. Un 429 quota_exceeded n'a pas de Retry-After : il aboutit donc au message d'erreur.
const button = form.querySelector("button");
const status = form.querySelector("[role=status]");
form.addEventListener("submit", async (event) => {
event.preventDefault();
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const retryAfter = response.headers.get("Retry-After");
if (response.status === 429 && retryAfter) {
let wait = Number(retryAfter);
button.disabled = true;
const timer = setInterval(() => {
status.textContent = `Too many messages. Try again in ${wait} seconds.`;
wait -= 1;
if (wait < 0) {
clearInterval(timer);
button.disabled = false;
status.textContent = "";
}
}, 1000);
return;
}
const result = await response.json();
status.textContent = result.ok ? "Thanks. We'll reply by email." : result.error.message;
});La même gestion dans un composant Vue : lisez Retry-After, désactivez le bouton et faites le compte à rebours dans une ref.
<script setup lang="ts">
import { onBeforeUnmount, ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const wait = ref(0);
const notice = ref("");
let timer: ReturnType<typeof setInterval> | undefined;
function countDown(seconds: number) {
wait.value = seconds;
clearInterval(timer);
timer = setInterval(() => {
wait.value -= 1;
if (wait.value <= 0) clearInterval(timer);
}, 1000);
}
async function send(event: SubmitEvent) {
notice.value = "";
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const retryAfter = Number(response.headers.get("Retry-After"));
if (response.status === 429 && retryAfter > 0) {
countDown(retryAfter);
return;
}
const result = await response.json();
notice.value = result.ok ? "Thanks. We'll reply by email." : result.error.message;
}
onBeforeUnmount(() => clearInterval(timer));
</script>
<template>
<form :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required />
<p role="status">{{ wait > 0 ? `Too many messages. Try again in ${wait} seconds.` : notice }}</p>
<button :disabled="wait > 0">Send</button>
</form>
</template>Sur Cloudflare Workers, le binding intégré est l'option la moins coûteuse :
[[ratelimits]]
name = "CONTACT_LIMIT"
namespace_id = "1001"
[ratelimits.simple]
limit = 5
period = 60const ip = request.headers.get("CF-Connecting-IP") ?? "no-ip";
const { success } = await env.CONTACT_LIMIT.limit({ key: `contact:${ip}` });
if (!success) return new Response("Too many messages", { status: 429, headers: { "Retry-After": "60" } });Cloudflare documente ce binding comme approximatif et compté séparément dans chaque emplacement : une attaque répartie peut donc le dépasser. Pour une limite exacte sur tous les emplacements, Cloudflare recommande un Durable Object. Derrière votre propre nginx, limit_req fait le comptage avant que votre application ne voie l'envoi :
limit_req_zone $binary_remote_addr zone=contact:10m rate=5r/m;
server {
location = /api/contact {
limit_req zone=contact burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3000;
}
}Par quoi compter
- IP et formulaire ensemble. Le choix habituel. Un visiteur ne peut pas inonder ce formulaire, et utiliser un autre formulaire de votre site ne consomme pas le même quota.
- Beaucoup de personnes, une IP. Les bureaux, écoles, opérateurs mobiles et le Wi-Fi des conférences placent beaucoup de personnes derrière une même adresse. Gardez la limite par IP assez souple pour elles, ou relevez-la les jours d'événement.
- La vraie IP du client. Derrière un proxy ou un CDN, lisez l'en-tête que définit votre hébergeur et ne faites confiance qu'à celui-là. Une valeur brute de
X-Forwarded-Forpeut être définie par le client. - L'IP de votre propre serveur. Si votre serveur transmet les envois à un autre service, tous les visiteurs y partagent l'IP de votre serveur. Limitez de votre côté, par IP du visiteur, avant de transmettre.
Les limites de débit d'Ikiform
Ikiform compte chaque envoi précisément, par IP de visiteur et par formulaire, avant de lire le corps, avec une recharge progressive plutôt que des fenêtres fixes. Chaque formulaire choisit un préréglage sous Rate limit. Une protection anti-flot de 600 par minute par IP sur tous les formulaires s'exécute avant chacun d'eux.
| Préréglage | Par visiteur | Convient à |
|---|---|---|
| Standard | 5 par minute, au plus 3 toutes les 10 secondes | La plupart des formulaires |
| Relaxed | 20 par minute | Événements sur un Wi-Fi partagé, formulaires derrière une connexion |
| Strict | 2 toutes les 10 minutes, un de plus toutes les 5 minutes | Formulaires de contact et commerciaux |
| Custom | 1 à 1 000 par 10 secondes, minute, 10 minutes, heure ou jour | Une inscription par personne, bornes |
Le formulaire entier accepte aussi 300 envois par minute pour l'ensemble des visiteurs, sauf si vous définissez un plafond Custom. Au-delà de la limite, un envoi du navigateur voit une page de ralentissement et fetch reçoit 429 rate_limited avec Retry-After, qu'Ikiform expose via CORS pour que le code ci-dessus puisse le lire. La documentation sur les limites de débit explique la recharge.
IKIFORM_SECRET_KEY dans des variables d'environnement réservées au serveur.Vérifiez que tout fonctionne
- Envoyez le formulaire plusieurs fois de suite. Au-delà de la limite, vous recevez
429avec un en-têteRetry-After, ou la page de ralentissement d'Ikiform. - Attendez le nombre de secondes indiqué dans
Retry-Afteret renvoyez. L'envoi est accepté. - Envoyez depuis un second réseau, par exemple votre téléphone hors Wi-Fi. Il a son propre quota.
- Si votre serveur transmet les envois, envoyez rapidement depuis deux appareils et vérifiez qu'aucun ne partage le compteur de l'autre.
Pièges
- Les envois refusés ne doivent pas consommer de quota. Comptez avant le travail, et ne laissez pas un envoi refusé repousser la fenêtre, sinon un visiteur nerveux se bloque lui-même.
- Ne limitez pas le chargement de la page. Comptez les envois, pas les affichages du formulaire, sinon une personne qui recharge épuise ses envois.
429 rate_limitedUn visiteur a envoyé plus que ce que permet la limite de débit du formulaire. Standard autorise 5 envois par minute par visiteur. AttendezRetry-After, ou choisissez Relaxed ou Custom pour les formulaires envoyés depuis une IP partagée.- IPv6. Une personne peut détenir de nombreuses adresses IPv6. Pour une limite stricte, comptez par préfixe
/64plutôt que par adresse complète.
Questions
Pour un formulaire de contact, quelques-uns par visiteur suffisent largement : le préréglage Strict d'Ikiform en autorise 2 toutes les 10 minutes. Les formulaires d'inscription et d'événement utilisés depuis des réseaux partagés en demandent plus, autour de 20 par minute.
Pas de façon fiable. Chaque instance serverless a sa propre mémoire et les instances vont et viennent : le compteur se réinitialise donc au hasard. Utilisez un stockage partagé comme Redis.
Un message simple indiquant combien de temps attendre, tiré de l'en-tête Retry-After, avec le bouton d'envoi désactivé jusque-là. N'effacez pas ce que la personne a tapé.
Oui. Chaque formulaire démarre sur le préréglage Standard, 5 par minute par visiteur, et vous pouvez passer à Relaxed, Strict ou Custom pour chaque formulaire.
Une étape vous bloque, ou vous voulez un guide pour votre cas ? Écrivez à hello@ikiform.com.