Handle contact form spam #373
Labels
No labels
contact
monthly-links
site content
spam
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
serious-devs/website#373
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Constat
Le formulaire de contact reçoit beaucoup de spam. Aujourd'hui, chaque soumission — humaine ou bot — part dans Forgejo via
contact.php(endpoint + token dansconfig/secrets.php) et arrive sous forme d'issue labellisée.Pourquoi c'est trivial à spammer
Le formulaire est un POST HTML pur (
action="/contact.php") :contact.phpvalide seulement que les champs obligatoires sont non vides et que l'email est bien formé — exactement ce qu'un bot envoie correctement du premier coup ;Contraintes (fixées d'office)
Périmètre
Les propositions sont dans les commentaires, une par approche, avec coût/bénéfice/limites. Objectif : en combiner deux ou trois légères plutôt qu'une lourde.
PS : les services anti-spam gratuits type Cloudflare Turnstile sont volontairement écartés (service externe + compte + lib JS = dépasse le cadre posé ici).
Option 1 — Honeypot (champ piège) — ~15 lignes
Principe : ajouter dans le formulaire un champ que les humains ne voient pas (caché en CSS, pas
type=hiddenque les bots détectent trop facilement) :Côté
contact.php: siwebsiteest non vide → soumission bot → rejet silencieux (rediriger vers#contact-successpour ne pas apprendre au bot qu'il a été détecté, et ne pas polluer les logs).Pourquoi ça marche : les bots génériques remplissent tous les champs
requiredoutextqu'ils rencontrent — la grande majorité du spam de formulaire vient de ces bots non ciblés.Limites : un bot ciblé sur CE formulaire (qui lit le CSS ou ne remplit que les champs connus) passera au travers. Seul, ce n'est pas une forteresse — c'un premier filtre quasi gratuit.
Verdict : à faire dans tous les cas, rien que pour le rapport coût/bénéfice.
Option 2 — Jeton signé + délai minimum — ~25 lignes
Principe : bloquer les bots qui POSTent directement sur
contact.phpsans jamais charger la page (le cas majoritaire ici, puisque le formulaire est un POST HTML pur).contact.phpsert aussi le GET : surGET /contact.php, il renvoie un jetonHMAC(timestamp, secret)(secret = une ligne de plus danssecrets.php) :Le formulaire le récupère au chargement de la page (petit
fetchJS, ou champ rempli à la volée) et l'envoie avec le POST. Côté serveur :Pourquoi ça marche : casse le POST « à l'aveugle ». Un bot doit maintenant GET le jeton, attendre 5 s, puis POSTer — ce qui casse la plupart des scripts tout faits, et rend le flood en boucle inefficace (1 soumission max / 5 s / jeton).
Limites : requiert JS pour remplir le champ (les rares visiteurs sans JS devront passer par le mailto — acceptable). Un bot écrit spécifiquement contre ce site contourne le délai, pas la signature.
Verdict : le meilleur rapport protection/complexité pour notre cas précis. Se combine très bien avec l'option 1.
Option 3 — Question simple (captcha maison) — ~10 lignes
Principe : remplacer le champ honeypot par une question triviale que tout humain répond et qu'un bot générique rate : « Combien font 3 + 4 ? », ou une question français/métier (« Quel mot-clé Java déclare une classe ? » — humoristique et dans le thème du site).
Vérification côté serveur, réponse stockée en hash (pas en clair) :
On peut décliner 3-4 questions et choisir aléatoirement côté template à chaque build.
Pourquoi ça marche : un parseur générique de formulaire ne sait pas répondre « 7 » à une question libre — c'est un vrai test cognitif minimal.
Limites :
Verdict : simple mais friction côté humain et obsolescence face aux bots LLM. Je le classerais derrière les options 1 et 2. Utile seulement si le spam résiste au combo 1+2.
Option 4 — Rate-limit par IP (fichier plat) — ~20 lignes
Principe : plafonner le nombre de soumissions acceptées par IP, indépendamment du reste :
Avec un compteur réinitialisé par fenêtre (heure courante encodée dans le nom du fichier, ex.
contact_rl_<md5ip>_<yyyymmddhh>→ expiration naturelle).Pourquoi ça marche : même un bot qui passe les autres filtres ne peut plus inonder — le pire scénario devient « 3 messages de spam par heure au lieu de centaines ».
Limites : un bot réparti sur plusieurs IP (botnets résidentiels) contourne le plafond par IP ; écritures disque à chaque POST (négligeable à notre échelle) ; sur hébergement mutualisé, ne pas stocker hors
sys_get_temp_dir().Verdict : filet de sécurité bas de spectre, à combiner aux autres. Seul, il ne filtre rien, il limite la casse.
Option 5 — Flaguer au lieu de rejeter (complément transversal) — ~5 lignes
Pour toute soumission suspecte (honeypot rempli, jeton douteux, contenu à base de liens), au lieu de jeter silencieusement : créer quand même l'issue Forgejo mais avec un second label
suspect/spam, et garder le label habituel pour les messages conformes.Verdict : recommandé en accompagnement, surtout au début quand on ne sait pas encore ce que les filtres laissent passer.
Recommandation
Combo option 1 + 2 (+ 5 en accompagnement) : honeypot + jeton signé avec délai minimum, et label
suspectplutôt que rejet sec. Ordre de grandeur : ~40 lignes au total (une trentaine danscontact.php, un champ danscontact.html, une règle CSS), aucun service externe, aucune infra.Ce combo élimine les deux profils de spam dominants :
Si le spam résiste à ce combo, on réévalue (option 3, ou question de réexaminer Turnstile à ce moment-là).
Je peux préparer la PR dès que tu valides l'approche.
PR #374 ouverte : #374 — combo 1+2+5 implémenté et testé de bout en bout. Points d'attention dans la description de la PR : 2 clés à ajouter dans
config/secrets.phpcôté OVH (CONTACT_SECRETrequis,CONTACT_SPAM_LABEL_IDoptionnel) avant de taguer le déploiement.