Skip to content

Rejeter les propositions de talk qui n'ont jamais chargé le formulaire - #380

Open
navidemad wants to merge 3 commits into
parisrb:masterfrom
navidemad:reject-talk-spam-that-never-fetched-the-form
Open

Rejeter les propositions de talk qui n'ont jamais chargé le formulaire#380
navidemad wants to merge 3 commits into
parisrb:masterfrom
navidemad:reject-talk-spam-that-never-fetched-the-form

Conversation

@navidemad

@navidemad navidemad commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Pourquoi

Trois soumissions de bots sont passées par le formulaire de talk ces derniers temps. Le honeypot faisait son travail, mais il ne peut attraper qu'un bot qui charge le formulaire et remplit tous les champs qu'il y trouve. Rien n'empêchait un script de poster directement sur POST /talks avec les params récupérés une fois pour toutes : pas de champ honeypot dans la requête, donc pas de honeypot à déclencher.

Ce qui change

Un stamp signé, rendu à côté du honeypot et vérifié à la soumission :

  • form_stamp porte l'heure de rendu du formulaire, signée avec Rails.application.message_verifier. Pas de stamp, ou un stamp que nous n'avons pas signé, veut dire que le formulaire n'a jamais été chargé : la soumission est jetée.
  • Un stamp de moins de 3 secondes veut dire que le formulaire a été rempli plus vite que personne ne tape : la soumission est jetée.
  • Un stamp de plus de 24 heures ne vaut plus rien. Sans cette borne, un stamp récupéré une seule fois se rejoue pour toujours et ne vaut pas mieux qu'une constante cachée dans le formulaire (merci @sunny).
  • Les deux premiers cas sont loggés avec l'IP et le user agent. Un rejet était muet jusqu'ici, donc impossible de savoir si le honeypot attrapait quelque chose, ou s'il mangeait de vraies propositions.

Le stamp périmé est le seul rejet qu'un humain peut déclencher, alors il est traité à part : le formulaire revient avec ce qui a été saisi, un stamp frais et un message, au lieu d'un renvoi muet vers l'accueil. Le stamp est aussi réutilisé quand une soumission revient sur une erreur de validation, pour que quelqu'un qui corrige une typo et renvoie dans la foulée ne soit pas pris pour un bot.

Et un plafond de 10 propositions par heure et par IP, avec le rate_limit natif de Rails 8.1. Borner le stamp ne corrige pas le rejeu, ça le rend seulement périssable : un bot qui recharge le formulaire une fois par jour repasse. Le plafond est ce qui limite vraiment ce qu'il peut envoyer. Il lui faut un cache partagé, d'où Redis en production — le défaut est un file store local au dyno, qui ne plafonne rien — et un memory store en test, où le null store rendait les compteurs muets. Une panne Redis rend le plafond inerte, elle ne casse pas les requêtes.

Le honeypot gagne au passage deux petites corrections. Il est maintenant hors de l'ordre de tabulation avec autocomplete="off", pour qu'un gestionnaire de mots de passe ou une navigation au clavier ne le remplisse pas par accident. Et sa variante height:0; width:0; opacity:0 passe en position:absolute : dans le flux, elle comptait quand même comme flex item et ajoutait un gap à la rangée de selects, un rendu sur quatre.

Pourquoi pas invisible_captcha

invisible_captcha fait les mêmes contrôles, plus un spinner qui fait doublon avec le timestamp. Sa moitié honeypot est plus faible ici : InvisibleCaptcha.honeypots vaut par défaut cinq noms générés au boot, par process. Avec plusieurs workers Puma, le formulaire rendu par l'un porte un nom dont le worker qui traite le POST n'a jamais entendu parler, et le contrôle passe en silence. Le rendre fiable demande de figer cette liste dans un initializer, et de remplacer la réponse par défaut head(200), que Turbo affiche comme un formulaire qui ne fait rien. Ça fait beaucoup de précautions pour remplacer un concern qu'on a déjà et qu'on teste.

Elle n'échappe pas non plus au rejeu : son timestamp vit dans la session, donc dans un cookie, et un bot qui renvoie son cookie d'origine repasse indéfiniment, le session.delete ne touchant que le cookie de réponse qu'il jette.

Tests

  • Rejetés désormais : pas de stamp, un stamp forgé, un stamp qui n'est pas une chaîne (form_stamp[]=x), un stamp de moins de 3 secondes, un stamp de plus de 24 heures.
  • Un formulaire périmé revient avec les valeurs saisies et un stamp qui marche. Sans ce dernier point, renvoyer le stamp périmé enfermerait la personne dans une boucle de rejets.
  • Le stamp survit à une erreur de validation, donc un renvoi corrigé passe.
  • La 11e proposition en une heure depuis la même adresse est refusée.
  • Le test honeypot existant envoie maintenant un stamp valide : il ne peut plus échouer que sur le honeypot.
  • Les rejets apparaissent dans les logs.
  • Chaque règle a été mutation-testée : la désactiver dans le concern casse exactement les tests qui la couvrent, et aucun autre.
  • Les system tests attendent avant de soumettre. Sans cette attente, Selenium remplit le formulaire en moins de 3 secondes et se fait refouler : c'est la garde qui fait son travail.
  • Gates : rails test:all (47 runs, 0 échec), rubocop, erb_lint, brakeman, yarn standard.

Reste

Rien de tout ça n'arrête un bot qui pilote un vrai navigateur avec un LLM pour remplir les champs, il rentre dans le plafond horaire comme un humain. Si le spam continue, la marche suivante est Turnstile ou Altcha, pas un honeypot plus malin.

The honeypot only catches a bot that fetches the form and fills in every
input it finds. A script posting straight to POST /talks with params it
harvested once sends no honeypot field at all, so there is nothing to
trip, and it goes through.

Add a signed stamp next to the honeypot, carrying the time the form was
rendered. No stamp, or one we did not sign, means the form was never
fetched. A stamp under three seconds old means the form was not filled
in by a human. Log both with the IP and user agent: until now a blocked
submission was silent, so there was no way to tell whether the honeypot
was catching anything or eating real proposals.
@navidemad navidemad changed the title Reject talk proposals that never fetched the form Rejeter les propositions de talk qui n'ont jamais chargé le formulaire Aug 12, 2026
Comment thread app/controllers/concerns/captcha.rb Outdated
elsif age.nil?
"form was never fetched"
elsif age < MIN_FILL_TIME
"form was submitted in #{age}s"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Est-ce qu'il ne faudrait pas quand même un max ici, même s'il est de 24h ?

Si la preuve n'a pas d'expiration ça veut dire qu'on peut la réutiliser indéfiniement, ce qui équivaudrait à ajouter un <input name="check" value="foobar" /> et juste vérifier que la valeur est foobar et espérer que les bots ne prennent pas cet input, non ?

@navidemad navidemad Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tu as raison. La borne est en place : MAX_FORM_AGE = 24.hours.

Mon argument dans la description visait une expiration courte. À 24 heures, un humain ne perd rien. Cet argument ne défendait donc pas l'absence de borne.

Un bot passe le honeypot s'il ne fait rien : il ignore un champ. Un bot passe le stamp seulement s'il agit. Il appelle notre endpoint, puis il garde la valeur. Un script générique ne peut ni la deviner dans le HTML, ni l'écrire en dur.

Nos 3 spams sont des POST directs. Ils restent bloqués dans tous les cas. Mais un bot qui charge le formulaire une fois obtient une valeur réutilisable. C'est le cas que tu décris.

La borne ne supprime pas le rejeu. Elle limite sa durée. Un bot recharge le formulaire une fois par jour, et il repasse.

invisible_captcha a la même limite. Son timestamp vit dans la session, donc dans un cookie. Un bot renvoie son cookie d'origine et repasse. Le session.delete modifie seulement le cookie de la réponse, et le bot ignore cette réponse. Aucune solution sans état serveur ne corrige le rejeu. Il faut un nonce à usage unique.

J'ai donc ajouté un plafond : rate_limit to: 10, within: 1.hour par IP sur create. Rails 8.1 le fournit. Il demande un cache partagé. La production utilise maintenant Redis, car le file store par défaut est local au dyno et ne plafonne rien. Les tests utilisent un memory store, car le null store rend les compteurs muets.

Ta remarque a fait sortir deux détails.

Le formulaire re-rendu ne reçoit jamais un stamp périmé. Sinon la personne reçoit un rejet à chaque envoi. Un test couvre ce cas.

Nous calculons l'âge du stamp nous-mêmes, au lieu de passer expires_in: au verifier. Nous distinguons donc un stamp périmé d'un stamp forgé. Avec un stamp périmé, le formulaire revient avec les valeurs saisies et un message. Avec un stamp forgé, la requête part vers l'accueil, sans message.

Sans expiration, un stamp récupéré une seule fois se rejoue
indéfiniment : il ne vaut alors pas mieux qu'une constante cachée dans
le formulaire, comme le relève la review.

Le stamp expire donc au bout de 24 h. C'est le seul rejet qu'un humain
peut déclencher, alors le formulaire lui revient avec ce qu'il avait
saisi, un stamp frais et un message, plutôt qu'un renvoi muet vers
l'accueil.

Borner ne suffit pas à empêcher le rejeu, ça le rend juste périssable :
un bot n'a qu'à recharger le formulaire une fois par jour. Le plafond de
10 propositions par heure et par IP est ce qui limite vraiment ce qu'il
peut envoyer. Il lui faut un cache partagé, d'où Redis en production, et
un memory store en test, où le null store rendait les compteurs muets.

@sunny sunny left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super propre 🔥

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants