Un formulaire d'inscription public qui envoie un e-mail à l'adresse saisie est, pour un robot, un relais d'envoi gratuit. Il crée des comptes fantômes, et surtout il peut faire partir des dizaines de messages vers la boîte d'un tiers, depuis votre domaine et avec votre réputation d'expéditeur. Le formulaire « mot de passe oublié » a exactement le même profil. Le réflexe habituel est d'ajouter un captcha : une friction pour chaque visiteur, un service tiers de plus à déclarer, et un obstacle réel pour une partie des utilisateurs.
Sur Qezako, une plateforme SaaS d'étiquetage numérique pour les marques, j'ai traité ce problème autrement : quatre correctifs de quelques dizaines de lignes chacun, sans captcha ni dépendance externe, avec les outils que Symfony fournit déjà. Cet article les détaille, avec la leçon que chacun porte, et une limite que je préfère poser d'emblée : aucun ne prétend arrêter un robot déterminé. Ils rendent l'abus coûteux et borné, ce qui suffit pour la grande majorité des robots qui écument les formulaires.
Ce qu'il faut vraiment protéger
Avant de choisir une parade, il faut nommer ce qui coûte. Un compte fantôme en base est gênant, mais il ne fait de tort à personne. L'e-mail envoyé, lui, a un coût réel : il atterrit chez quelqu'un qui n'a rien demandé, il pèse sur la réputation du domaine expéditeur, et une fois parti, on ne peut plus le rattraper. La cible est donc moins le compte que l'envoi.
De là découle une défense en quatre couches, chacune bon marché et chacune pensée pour un type de robot :
- un champ piège — contre le robot qui remplit tous les champs qu'il trouve dans le HTML
- une durée minimale de remplissage — contre le robot qui soumet le formulaire dès qu'il l'a reçu
- un plafond d'envois par adresse IP — contre celui qui passe les deux premiers et insiste
- une purge des inscriptions jamais confirmées — pour ce qui passe malgré tout
Les deux premières couches ont une limite connue, que j'ai écrite noir sur blanc dans le code : un robot qui pilote un vrai navigateur et prend son temps les passe toutes les deux. C'est la raison d'être des deux suivantes.
Un champ que seul un robot remplit
Le principe du honeypot est ancien : on ajoute au formulaire un champ qu'un humain ne voit pas, et toute soumission qui le remplit vient d'un robot. Le détail de la mise en œuvre compte plus que le principe.
-
pas de
type="hidden"— un robot un peu soigné sait ignorer les champs cachés. Le champ est un champ texte ordinaire, déplacé hors de l'écran par une classe CSS -
un nom et un libellé plausibles — un nom qui ressemble à une vraie information à saisir
attire bien plus de robots qu'un champ nommé
trap. Choisissez le vôtre, propre à votre formulaire -
invisible aussi pour les technologies d'assistance —
aria-hiddensur le conteneur ettabindex="-1"sur le champ, pour qu'un lecteur d'écran ne l'annonce pas et que la navigation au clavier ne s'y arrête pas -
hors du modèle —
mapped: falseetrequired: false: l'entité n'en sait rien
$builder
->add(BotTraps::HONEYPOT_FIELD, TextType::class, [
'label' => 'Page personnelle',
'mapped' => false,
'required' => false,
'attr' => ['autocomplete' => 'off', 'tabindex' => '-1'],
]);
Le rendu place ce champ dans un conteneur dédié, positionné loin hors de l'écran plutôt que masqué
par display: none, que certains robots savent aussi détecter :
<div class="form-trap" aria-hidden="true">
<!-- the honeypot row is rendered here -->
</div>
Reste la question la plus intéressante : que répondre au robot ? Surtout pas une erreur. Un message « vous êtes un robot » lui apprend exactement quel champ éviter. Il reçoit donc la même page que n'importe quel inscrit, celle qui l'invite à consulter sa boîte de réception, sauf que rien n'a été créé et qu'aucun e-mail n'est parti.
$form->handleRequest($request);
// The bot must not learn it was caught: it gets the same page as a real sign-up.
if ($form->isSubmitted() && $botTraps->isHoneypotFilled($form->get(BotTraps::HONEYPOT_FIELD)->getData())) {
return $this->redirectToRoute('app_register_sent');
}
Ce choix a un revers qu'il faut connaître. Un humain qui remplit le champ piège est rejeté en silence,
sans aucun moyen de le savoir. Or les navigateurs et les gestionnaires de mots de passe ne respectent pas tous
autocomplete="off", et un nom de champ trop proche d'une donnée qu'ils savent remplir
(adresse, téléphone, société) les expose à remplir le piège à la place de l'utilisateur.
Le nom doit donc être plausible pour un robot, mais ne correspondre à aucune donnée de formulaire courante.
Une durée minimale de remplissage, signée
Un humain met plusieurs secondes à remplir un nom et une adresse. Un robot naïf soumet le formulaire dans la même seconde que son affichage. La deuxième couche mesure cet écart : au rendu, le formulaire embarque l'heure d'affichage dans un champ caché ; à la soumission, une contrainte refuse l'envoi s'il arrive trop tôt.
Le point délicat est la confiance accordée à cette heure. Si elle circule en clair, le robot n'a qu'à inventer une heure ancienne. Elle est donc signée avec le secret de l'application, et la vérification échoue en mode fermé : un horodatage absent, illisible, falsifié ou qui n'est pas un nombre compte comme « trop rapide ».
La signature a une limite qu'il faut connaître : elle empêche d'inventer une heure, pas de réutiliser une vraie. Un robot qui charge le formulaire une seule fois peut rejouer indéfiniment le même tampon, déjà assez ancien. Borner aussi l'âge du tampon, par exemple refuser une soumission affichée depuis plus d'une heure, réduit cette fenêtre. Le rejeu reste alors possible pendant cette heure, et c'est le plafond par adresse IP, plus bas, qui en borne l'effet.
<?php
declare(strict_types=1);
namespace App\Security;
use SensitiveParameter;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class BotTraps
{
public const string HONEYPOT_FIELD = 'homepage';
public const string STAMP_FIELD = 'rendered_at';
public function __construct(
#[SensitiveParameter]
#[Autowire('%kernel.secret%')]
private string $secret,
#[Autowire('%app.form_min_fill_seconds%')]
private int $minFillSeconds,
) {
}
public function stamp(?int $now = null): string
{
$renderedAt = (string)($now ?? time());
return $renderedAt . '.' . $this->sign($renderedAt);
}
public function isHoneypotFilled(mixed $value): bool
{
return is_string($value) && '' !== trim($value);
}
public function isFilledTooFast(mixed $stamp, ?int $now = null): bool
{
if (!is_string($stamp) || 1 !== substr_count($stamp, '.')) {
return true;
}
[$renderedAt, $signature] = explode('.', $stamp);
if (!ctype_digit($renderedAt) || !hash_equals($this->sign($renderedAt), $signature)) {
return true;
}
return ($now ?? time()) - (int)$renderedAt < $this->minFillSeconds;
}
private function sign(string $value): string
{
// A dedicated prefix keeps this signature from being valid anywhere else the secret signs.
return hash_hmac('sha256', 'bot-traps/' . $value, $this->secret);
}
}
Le paramètre $now n'est là que pour les tests : il permet de vérifier la frontière exacte
sans dépendre de l'horloge. Dans le formulaire, le champ caché reçoit l'horodatage au rendu
et porte une contrainte Callback :
->add(BotTraps::STAMP_FIELD, HiddenType::class, [
'mapped' => false,
'data' => $this->botTraps->stamp(),
'constraints' => [
new Callback(function (mixed $stamp, ExecutionContextInterface $context): void {
if ($this->botTraps->isFilledTooFast($stamp)) {
$context->addViolation('Formulaire envoyé trop vite. Patientez quelques secondes, puis renvoyez-le.');
}
}),
],
]);
Ici, contrairement au champ piège, la réponse est une erreur explicite, et c'est volontaire. Personne ne remplit un champ invisible par accident, mais un humain pressé peut très bien être rapide : un navigateur qui remplit nom et e-mail tout seul, et il ne reste qu'à cliquer. Le message lui dit quoi faire. Le seuil, lui, reste de quelques secondes : assez pour écarter le robot naïf, assez court pour qu'un humain ne le rencontre presque jamais.
Des tests de bout en bout qui prennent leur temps
Ce piège a un effet de bord sur les tests de bout en bout : un test qui remplit le formulaire à la vitesse d'un script est désormais rejeté, à juste titre. Il faut un helper qui attend le délai, compté depuis l'affichage de la page, plutôt qu'une pause fixe ajoutée après la saisie : le test ne perd que le temps nécessaire, et une saisie plus lente ne le rallonge pas.
// Waits out the minimum fill time counted from the page display, with a small safety margin.
export async function register(page: Page, name: string, email: string): Promise<void> {
await page.goto('/inscription');
const renderedAt = Date.now();
await page.locator('#user_register_name').fill(name);
await page.locator('#user_register_email').fill(email);
await page.waitForTimeout(Math.max(0, MIN_FILL_MS + 100 - (Date.now() - renderedAt)));
await page.getByRole('button', { name: "S'inscrire" }).click();
}
Plafonner les envois par adresse IP
Les deux premières couches arrêtent les robots simples. Contre celui qui pilote un navigateur et prend son temps, il reste à borner le dommage : quel que soit son comportement, une même adresse IP ne peut déclencher qu'un nombre limité d'envois par heure. Le composant RateLimiter de Symfony le fait en quelques lignes de configuration :
framework:
rate_limiter:
register:
policy: sliding_window
limit: 10
interval: '1 hour'
password_lost:
policy: sliding_window
limit: 10
interval: '1 hour'
Les valeurs ci-dessus sont illustratives : elles se calibrent sur votre propre trafic. La contrainte qui fixe le plancher est concrète : une entreprise dont tous les postes sortent par une seule IP doit pouvoir inscrire quelques collègues dans l'heure sans être bloquée.
Depuis Symfony 7.3, chaque limiteur s'injecte par son nom via RateLimiterFactoryInterface
($registerLimiter, $passwordLostLimiter). Deux choix d'implémentation comptent.
D'abord, le quota n'est consommé qu'après la validation du formulaire :
une saisie invalide ne coûte rien, seul un envoi consomme. Ensuite, le refus doit être lisible
à la fois par l'humain et par la machine : un message dans le formulaire, et un code HTTP 429 avec un en-tête
Retry-After. Pour relier les deux sans état supplémentaire, l'erreur de formulaire transporte
le résultat du limiteur comme cause, et le rendu le relit :
if ($form->isSubmitted() && $form->isValid() && $this->consumeMailQuota($registerLimiter, $request, $form)) {
// create the account and send the e-mail
}
return $this->render('security/register.html.twig', [
'form' => $form->createView(),
], $this->mailQuotaResponse($form));
// …
private function consumeMailQuota(RateLimiterFactoryInterface $limiter, Request $request, FormInterface $form): bool
{
$limit = $limiter->create($request->getClientIp())->consume();
if ($limit->isAccepted()) {
return true;
}
$minutes = max(1, (int)ceil(($limit->getRetryAfter()->getTimestamp() - time()) / 60));
$form->addError(new FormError("Trop de demandes depuis votre connexion. Réessayez dans {$minutes} min.", cause: $limit));
return false;
}
private function mailQuotaResponse(FormInterface $form): ?Response
{
foreach ($form->getErrors() as $error) {
$limit = $error->getCause();
if ($limit instanceof RateLimit) {
return new Response(status: Response::HTTP_TOO_MANY_REQUESTS, headers: [
'Retry-After' => (string)max(0, $limit->getRetryAfter()->getTimestamp() - time()),
]);
}
}
return null;
}
Le même plafond protège le formulaire « mot de passe oublié », qui envoie lui aussi un e-mail à une adresse saisie par n'importe qui. Il ne porte pas les deux pièges précédents, mais il était tout aussi exploitable pour inonder la boîte d'un tiers.
Le piège des proxys de confiance
Un limiteur par IP repose entièrement sur Request::getClientIp().
Et ce que renvoie cette méthode dépend d'un réglage que l'on oublie facilement : framework.trusted_proxies.
Il peut se tromper dans les deux sens, et aucun des deux ne casse visiblement.
Le même raisonnement vaut quand la méthode ne renvoie aucune adresse : toutes ces requêtes partagent alors une seule clé de quota.
-
trop de confiance — si l'application honore
X-Forwarded-Forsans borner qui a le droit de l'écrire, n'importe quel client choisit l'adresse qu'il présente. Le robot change d'IP à chaque requête, et le plafond ne s'applique plus à personne - pas assez — le jour où un répartiteur de charge ou un reverse proxy est placé devant l'application sans être déclaré, toutes les visites arrivent sous son adresse à lui. Le plafond par IP devient un plafond global : après une poignée de visiteurs dans l'heure, plus personne ne peut s'inscrire ni réinitialiser son mot de passe
La règle que j'ai documentée et figée par un test : la liste est vide par défaut, tant que rien ne s'intercale
entre le client et PHP ; elle reçoit l'adresse sous laquelle l'application voit le frontal, jamais l'IP publique du site,
le jour où un frontal apparaît. Symfony comprend deux raccourcis utiles dans cette liste :
REMOTE_ADDR, qui fait confiance à tout pair connecté et n'est sûr que si PHP est injoignable autrement que par le frontal,
et private_ranges, pratique quand le frontal parle à l'application sur un réseau privé.
framework:
trusted_proxies: '%env(default::SYMFONY_TRUSTED_PROXIES)%'
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-port']
Les en-têtes honorés sont limités au strict nécessaire. x-forwarded-host en est exclu :
sur une application où l'hôte de la requête pilote un comportement, comme des domaines personnalisés par client,
il ne doit pas pouvoir être réécrit par un simple en-tête.
Purger ce qui passe quand même
Aucune de ces couches n'est étanche. Il restera des inscriptions de robots, jamais confirmées, qui encombrent la base et faussent les statistiques. La dernière couche est une commande nocturne qui supprime les inscriptions dont l'e-mail n'a jamais été confirmé au bout de sept jours. Elle est courte, mais deux décisions y demandent du soin.
La première est la définition de « jamais confirmé ». Le réflexe est de s'appuyer sur le rôle de l'utilisateur, puisqu'un compte non vérifié porte un rôle dédié. Ce serait une erreur : un compte confirmé garde ce rôle tant qu'il n'a pas créé son organisation, et une purge sur le rôle supprimerait de vrais clients en cours d'installation. Le seul signal fiable est celui que seule la confirmation écrit : la date de premier choix du mot de passe, fixée par le lien envoyé par e-mail.
/**
* Only the e-mailed link sets the password, and with it `passwordSetAt`: a confirmed account
* still waiting to create its organisation keeps the unverified role and must not match.
*/
private function whereNeverConfirmed(QueryBuilder $qb, DateTime $createdBefore): QueryBuilder
{
return $qb
->andWhere($qb->expr()->eq('user.role', ':role'))
->andWhere($qb->expr()->isNull('user.organization'))
->andWhere($qb->expr()->isNull('user.passwordSetAt'))
->andWhere($qb->expr()->lt('user.createdAt', ':createdBefore'))
->setParameter('role', RoleEnum::UNVERIFIED)
->setParameter('createdBefore', $createdBefore);
}
La seconde est le délai de grâce. Le lien de confirmation expire au bout de quelques heures ; passé ce délai, l'e-mail renvoie l'utilisateur retardataire vers « mot de passe oublié ». Or ce formulaire n'envoie de message qu'à une adresse connue. Purger dès l'expiration du lien fermerait cette porte de rattrapage. Une semaine laisse au vrai utilisateur le temps de revenir.
La commande accepte une option --dry-run qui liste les comptes visés sans rien supprimer,
à lancer au moins une fois en production avant d'activer la tâche planifiée.
C'est aussi une mesure de minimisation des données au sens du RGPD :
une adresse saisie par un robot est souvent celle d'une vraie personne qui n'a rien demandé.
Tester les pièges, et leurs limites
Des défenses invisibles ont un défaut : on ne remarque pas qu'elles ont cessé de fonctionner. Chaque couche a donc ses tests, et le plus utile n'est pas celui qu'on écrit en premier.
- des tests unitaires à la frontière — une soumission au seuil exact passe, une seconde plus tôt elle est refusée ; un horodatage signé avec un autre secret, vide, illisible ou non numérique est refusé
- des tests de bout en bout qui comptent les e-mails — avec un serveur SMTP de test comme Mailpit, on vérifie que le champ piège rempli renvoie bien la page de succès, et qu'aucun message n'est parti. C'est cette seconde assertion qui protège réellement : un piège qui affiche la bonne page mais laisse partir l'e-mail ne sert à rien
- un test du quota — au-delà du plafond, la réponse passe en 429 et plus aucun e-mail ne part
Ces tests disent aussi ce que le dispositif ne fait pas. Un robot qui exécute le JavaScript, attend, ignore les champs invisibles et répartit ses envois sur de nombreuses adresses IP passera. Contre lui, il faudra d'autres outils : une vérification de réputation des adresses IP, un challenge ciblé sur les seules requêtes suspectes, ou un captcha déclenché seulement après plusieurs refus. L'intérêt des quatre couches est de ne réserver ces outils plus lourds qu'au cas où ils deviennent nécessaires, au lieu de les imposer à chaque visiteur. Elles s'inscrivent dans une démarche plus large de sécurisation d'une application en production, et la purge des comptes fantômes rejoint la checklist RGPD sur la conservation des données. Quant à savoir si vos formulaires publics sont exploitables aujourd'hui, c'est typiquement le genre de question qu'un audit de sécurité applicatif tranche vite.
Conclusion
Un captcha répond à la question « est-ce un humain ? » en la posant à tout le monde. Ces quatre correctifs posent une autre question : « combien d'e-mails ce visiteur peut-il faire partir ? ». Le champ piège et la durée minimale écartent les robots simples sans que personne ne s'en aperçoive, le plafond par IP borne le dommage des autres, et la purge nettoie ce qui reste. Aucun ne dépasse quelques dizaines de lignes. Leur valeur tient aux détails : répondre au robot comme à un humain, signer ce qui vient du client, choisir le bon signal de confirmation, et ne jamais oublier que l'adresse IP vue par l'application dépend de la configuration des proxys.
Message clé
Protégez l'envoi, pas le formulaire. Quelques pièges invisibles et un plafond par IP évitent le captcha à tous vos visiteurs.
Vous voulez savoir si vos formulaires publics peuvent servir de relais à des robots ? Un audit technique passe en revue les points d'entrée exposés et priorise les correctifs. Parlons-en.