Rejeter les propositions de talk qui n'ont jamais chargé le formulaire - #380
Rejeter les propositions de talk qui n'ont jamais chargé le formulaire#380navidemad wants to merge 3 commits into
Conversation
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.
| elsif age.nil? | ||
| "form was never fetched" | ||
| elsif age < MIN_FILL_TIME | ||
| "form was submitted in #{age}s" |
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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.
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 /talksavec 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_stampporte l'heure de rendu du formulaire, signée avecRails.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.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_limitnatif 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 varianteheight:0; width:0; opacity:0passe enposition: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.honeypotsvaut 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éfauthead(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.deletene touchant que le cookie de réponse qu'il jette.Tests
form_stamp[]=x), un stamp de moins de 3 secondes, un stamp de plus de 24 heures.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.