Handle contact form spam #373

Closed
opened 2026-11-13 07:31:42 +00:00 by hermes_webmaster · 6 comments
Collaborator

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 dans config/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") :

  • aucune protection côté serveur : contact.php valide 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 ;
  • aucun jeton, aucun délai minimum, aucune limite de débit : un bot peut POSTer en boucle sans même charger la page ;
  • le CSRF et le repeat sont libres aussi.

Contraintes (fixées d'office)

  • Pas de service externe payant (pas de captcha SaaS, pas de filtrage tiers)
  • Pas d'over-engineering : quelques dizaines de lignes max, pas d'infra supplémentaire
  • Rester sur l'hébergement OVH actuel (PHP)
  • Ne pas perdre un vrai message : en cas de doute, mieux vaut flagger que rejeter silencieusement

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).

## 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 dans `config/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"`) : - aucune protection côté serveur : `contact.php` valide 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 ; - aucun jeton, aucun délai minimum, aucune limite de débit : un bot peut POSTer en boucle sans même charger la page ; - le CSRF et le repeat sont libres aussi. ## Contraintes (fixées d'office) - **Pas de service externe payant** (pas de captcha SaaS, pas de filtrage tiers) - **Pas d'over-engineering** : quelques dizaines de lignes max, pas d'infra supplémentaire - Rester sur l'hébergement OVH actuel (PHP) - Ne pas perdre un vrai message : en cas de doute, mieux vaut flagger que rejeter silencieusement ## 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).*
Author
Collaborator

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=hidden que les bots détectent trop facilement) :

<div class="hp-field">
    <label for="website">Site web</label>
    <input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
.hp-field { position: absolute; left: -9999px; } /* dans sass/style.scss */

Côté contact.php : si website est non vide → soumission bot → rejet silencieux (rediriger vers #contact-success pour 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 required ou text qu'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 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=hidden` que les bots détectent trop facilement) : ```html <div class="hp-field"> <label for="website">Site web</label> <input type="text" id="website" name="website" tabindex="-1" autocomplete="off"> </div> ``` ```css .hp-field { position: absolute; left: -9999px; } /* dans sass/style.scss */ ``` Côté `contact.php` : si `website` est non vide → soumission bot → **rejet silencieux** (rediriger vers `#contact-success` pour 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 `required` ou `text` qu'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.
Author
Collaborator

Option 2 — Jeton signé + délai minimum — ~25 lignes

Principe : bloquer les bots qui POSTent directement sur contact.php sans jamais charger la page (le cas majoritaire ici, puisque le formulaire est un POST HTML pur).

contact.php sert aussi le GET : sur GET /contact.php, il renvoie un jeton HMAC(timestamp, secret) (secret = une ligne de plus dans secrets.php) :

$token = hash_hmac('sha256', strval(time()), $secrets['CONTACT_SECRET']);

Le formulaire le récupère au chargement de la page (petit fetch JS, ou champ rempli à la volée) et l'envoie avec le POST. Côté serveur :

  1. la signature HMAC est valide (jeton forgé ou absent → rejet) ;
  2. le timestamp a moins de ~24 h (page en cache longtemps ok) ;
  3. et le POST arrive plus de 5 s après l'émission du jeton — un humain ne remplit pas le formulaire en moins de 5 secondes, un script automatisé si.

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 2 — Jeton signé + délai minimum — ~25 lignes **Principe** : bloquer les bots qui POSTent directement sur `contact.php` sans jamais charger la page (le cas majoritaire ici, puisque le formulaire est un POST HTML pur). `contact.php` sert aussi le GET : sur `GET /contact.php`, il renvoie un jeton `HMAC(timestamp, secret)` (secret = une ligne de plus dans `secrets.php`) : ```php $token = hash_hmac('sha256', strval(time()), $secrets['CONTACT_SECRET']); ``` Le formulaire le récupère au chargement de la page (petit `fetch` JS, ou champ rempli à la volée) et l'envoie avec le POST. Côté serveur : 1. la signature HMAC est valide (jeton forgé ou absent → rejet) ; 2. le timestamp a moins de ~24 h (page en cache longtemps ok) ; 3. **et** le POST arrive plus de 5 s après l'émission du jeton — un humain ne remplit pas le formulaire en moins de 5 secondes, un script automatisé si. **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.
Author
Collaborator

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) :

if (hash('sha256', strtolower($answer)) !== $expected) { /* rejet silencieux */ }

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 :

  • frictions pour le vrai visiteur (petite, mais réelle) ;
  • de plus en plus contournable : les spams portés par des LLM répondent à ce genre de question sans problème ;
  • une question fixe finit par fuiter (les bots ciblés s'adaptent).

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 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) : ```php if (hash('sha256', strtolower($answer)) !== $expected) { /* rejet silencieux */ } ``` 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** : - frictions pour le vrai visiteur (petite, mais réelle) ; - **de plus en plus contournable** : les spams portés par des LLM répondent à ce genre de question sans problème ; - une question fixe finit par fuiter (les bots ciblés s'adaptent). **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.
Author
Collaborator

Option 4 — Rate-limit par IP (fichier plat) — ~20 lignes

Principe : plafonner le nombre de soumissions acceptées par IP, indépendamment du reste :

$file = sys_get_temp_dir() . '/contact_rl_' . md5($_SERVER['REMOTE_ADDR']);
$hits = (int)@file_get_contents($file);
if ($hits >= 3) { fail_redirect('rate limited'); }  // fenêtre glissante simple
file_put_contents($file, $hits + 1, LOCK_EX);

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.

  • zéro perte de vrai message (faux positif = simple triage dans Forgejo) ;
  • le spam reste visible pour mesurer l'efficacité des filtres, puis on ferme/épave en masse ;
  • s'ajoute à n'importe laquelle des options ci-dessus.

Verdict : recommandé en accompagnement, surtout au début quand on ne sait pas encore ce que les filtres laissent passer.

## Option 4 — Rate-limit par IP (fichier plat) — ~20 lignes **Principe** : plafonner le nombre de soumissions acceptées par IP, indépendamment du reste : ```php $file = sys_get_temp_dir() . '/contact_rl_' . md5($_SERVER['REMOTE_ADDR']); $hits = (int)@file_get_contents($file); if ($hits >= 3) { fail_redirect('rate limited'); } // fenêtre glissante simple file_put_contents($file, $hits + 1, LOCK_EX); ``` 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. - zéro perte de vrai message (faux positif = simple triage dans Forgejo) ; - le spam reste visible pour mesurer l'efficacité des filtres, puis on ferme/épave en masse ; - s'ajoute à n'importe laquelle des options ci-dessus. **Verdict** : recommandé en accompagnement, surtout au début quand on ne sait pas encore ce que les filtres laissent passer.
Author
Collaborator

Recommandation

Combo option 1 + 2 (+ 5 en accompagnement) : honeypot + jeton signé avec délai minimum, et label suspect plutôt que rejet sec. Ordre de grandeur : ~40 lignes au total (une trentaine dans contact.php, un champ dans contact.html, une règle CSS), aucun service externe, aucune infra.

Ce combo élimine les deux profils de spam dominants :

  • le bot qui POSTe en boucle sans charger la page → jeton + délai ;
  • le bot qui parse et remplit tout → honeypot ;
  • et ce qui passe au travers reste plafonné et identifiable (option 4 en filet si besoin plus tard).

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.

## Recommandation **Combo option 1 + 2 (+ 5 en accompagnement)** : honeypot + jeton signé avec délai minimum, et label `suspect` plutôt que rejet sec. Ordre de grandeur : ~40 lignes au total (une trentaine dans `contact.php`, un champ dans `contact.html`, une règle CSS), aucun service externe, aucune infra. Ce combo élimine les deux profils de spam dominants : - le bot qui POSTe en boucle sans charger la page → jeton + délai ; - le bot qui parse et remplit tout → honeypot ; - et ce qui passe au travers reste plafonné et identifiable (option 4 en filet si besoin plus tard). 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.
Author
Collaborator

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.php côté OVH (CONTACT_SECRET requis, CONTACT_SPAM_LABEL_ID optionnel) avant de taguer le déploiement.

PR #374 ouverte : https://factory.serious-devs.io/serious-devs/website/pulls/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.php` côté OVH (`CONTACT_SECRET` requis, `CONTACT_SPAM_LABEL_ID` optionnel) avant de taguer le déploiement.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
serious-devs/website#373
No description provided.