Mon captcha n'a jamais protégé contre le spam, juste contre la flemme

En mars, un bot nommé RobertqueRy m'avait spammé un article sur gRPC avec une pub pour des jeux d'argent au Bangladesh. J'avais ajouté un honeypot, un token CSRF, un rate-limit par IP et un captcha mathématique. Dans la conclusion de cet article, j'avais écrit une phrase que j'aurais dû prendre plus au sérieux : « un bot dédié qui parse le HTML pourrait le contourner ». Cinq mois plus tard, deux commentaires sont apparus sur mon article expliquant comment fonctionne mon système de commentaires. Le sujet est presque drôle. Le contenu, moins : une pub pour de la location de voiture à Marrakech, avec lien en dur.

Les quatre couches ont laissé passer le bot. Toutes. Pas une brèche, un contournement propre.

Ce qui a vraiment échoué

Le honeypot est un champ caché que seul un bot remplit. Vide : le bot ne l'a pas rempli, il a chargé la page et lu le formulaire correctement. Le token CSRF change à chaque session et doit correspondre à celui envoyé avec le POST : présent et valide, le bot a donc fait une vraie requête GET avant son POST, comme un navigateur normal. Le rate-limit autorise 3 commentaires par IP toutes les 10 minutes : un seul commentaire posté, aucune raison de se déclencher. Restait le captcha, ma dernière ligne de défense, une addition à un chiffre affichée en clair sur la page.

C'est là que j'ai compris mon erreur de mars. Le captcha n'était pas rendu en image, il n'était protégé par aucune obfuscation. C'était juste deux nombres et un signe +, en texte brut, dans le HTML :

<label for="bk-captcha">Vérification anti-spam : 6 + 1 = ?</label>

Un bot qui télécharge la page et fait un grep sur les chiffres n'a même pas besoin de faire semblant de comprendre l'addition. Il l'extrait, la calcule, et poste. C'est exactement le test que j'ai fini par écrire moi-même pour vérifier mon correctif, sans y penser sur le moment : un script qui charge la page, extrait le token CSRF et les deux opérandes du captcha avec deux lignes de grep, calcule la somme, et poste. Trois minutes de script bash. Si je peux automatiser la résolution de mon propre captcha en trois minutes un vendredi matin, un vrai bot de spam l'a automatisée depuis longtemps.

Le problème de fond : je vérifiais le mauvais truc

Honeypot, CSRF, rate-limit, captcha : les quatre couches répondent à la même question, « est-ce qu'un humain a rempli ce formulaire normalement ? ». Le bot qui m'a spammé a répondu oui aux quatre. Il a chargé la page, laissé le piège vide, récupéré le bon token, respecté la limite de fréquence, et résolu une addition à un chiffre. De son point de vue, c'était un visiteur humain qui remplit un formulaire une fois. Ce que ces couches ne demandent jamais, c'est ce que contient le commentaire.

C'est le trou. Un système entier de vérification de l'identité du visiteur, et zéro ligne qui regarde ce qu'il essaie de publier. Deux commentaires vantant la location de voiture à Marrakech et Cesme sont passés à travers quatre couches de sécurité sans qu'aucune ne lise leur contenu.

Le vrai filtre : juger le contenu, pas le visiteur

Le site tourne trois formulaires de commentaires quasi identiques (blog, fiches de lecture, pages skills), chacun avec son propre fichier de traitement, dupliqués volontairement plutôt que centralisés dans une lib partagée : trois petits fichiers PHP indépendants coûtent moins cher à comprendre qu'une abstraction commune. J'ai ajouté le même bloc aux trois. Deux règles :

// Anti-spam : liens externes et mots-clés typiques du spam de commentaires
$linkText = $author . ' ' . $content;
$hasExternalLink = (bool)preg_match('/<a\b/i', $linkText);
if (!$hasExternalLink && preg_match_all('/https?:\/\/\S+|www\.\S+/i', $linkText, $urlMatches)) {
    foreach ($urlMatches[0] as $url) {
        if (!preg_match('/^(https?:\/\/)?(www\.)?web-developpeur\.com/i', $url)) {
            $hasExternalLink = true;
            break;
        }
    }
}
if ($hasExternalLink) {
    // rejeter : lien externe
}

$spamWords = ['rent a car', 'car rental', 'car hire', 'escort', 'casino', 'viagra', 'cialis', 'forex trading', 'crypto signals', 'seo services', 'backlink', 'loan offer'];
foreach ($spamWords as $spamWord) {
    if (stripos($content, $spamWord) !== false || stripos($author, $spamWord) !== false) {
        // rejeter : mot-clé de spam
    }
}

Aucun lien externe, aucun des mots-clés qui reviennent dans 90 % du spam de commentaires. Ce n'est pas élégant, ce n'est pas du machine learning, c'est une liste de motifs qui décrit ce à quoi ressemble le spam qu'on a réellement reçu. Et contrairement au captcha, ça ne demande rien au visiteur : un humain qui écrit un vrai commentaire n'a presque jamais besoin de coller un lien ou de dire « rent a car ».

Le piège du filtre trop strict

Presque jamais, pas jamais. Un lecteur, Domenico, avait un jour signalé une page en erreur 500 en collant l'URL cassée dans son commentaire. Un filtre « zéro lien » aurait rejeté ce signalement de bug exactement comme il rejette une pub de location de voiture, et je ne m'en serais rendu compte que si Domenico avait insisté. C'est le danger d'un filtre trop large : il ne prévient pas quand il se trompe, il rejette en silence.

La correction est dans la même regex : les liens vers web-developpeur.com lui-même restent tolérés. Le filtre bloque ce qui fait sortir le lecteur du site, pas ce qui l'aide à signaler un problème dessus. La nuance tient en une ligne de code, mais il fallait avoir le cas réel sous les yeux pour y penser.

Tester le correctif avec le même tour de passe-passe que le bot

Un php -l qui passe ne prouve rien sur le comportement réel du formulaire. Pour vérifier, j'ai rejoué exactement ce que fait un bot un peu sérieux : charger la page en production, en extraire le token CSRF et les opérandes du captcha, calculer la somme, et poster un vrai commentaire avec curl. Deux essais : un commentaire avec un lien externe, un commentaire propre.

PAGE=$(curl -s -c cookies.txt "https://www.web-developpeur.com/blog/deuxieme-spam-commentaires-liens-php")
CSRF=$(echo "$PAGE" | grep -oE 'name="csrf_token" value="[^"]*"' | sed -E 's/.*value="([^"]*)"/\1/')
# ... même lecture du captcha que ferait un bot

curl -s -b cookies.txt -D - "https://www.web-developpeur.com/blog/comment-handler" \
  --data-urlencode "content=Check my site https://spam-example.test for deals" \
  --data-urlencode "csrf_token=$CSRF" --data-urlencode "captcha=$SUM" | grep Location
# → comment_error=Seuls+les+liens+vers+ce+site+sont+autorises...

Le lien externe a été rejeté, le commentaire propre a été publié. Ce deuxième commentaire de test s'est retrouvé sur le vrai fichier de commentaires en production, exactement comme celui du bot cinq mois plus tôt. Je l'ai supprimé à la main par FTP dans la minute qui a suivi, avant de le laisser traîner devant de vrais lecteurs.

Ce qu'il faut retenir

Un captcha en texte brut ne teste pas si tu es humain, il teste si tu sais parser un DOM. J'ai passé cinq mois à croire que mes quatre couches protégeaient contre le spam, alors qu'elles protégeaient contre un profil de bot précis : celui qui poste en aveugle sans lire la page. Contre un bot qui la lit, elles ne sont que de la friction.

La vraie protection n'a jamais été de prouver qu'un visiteur est humain. C'est de regarder ce qu'il essaie de publier. Ça semble évident écrit comme ça. Ça ne l'était pas avant que deux pubs pour des voitures de location au Maroc me le rappellent.

📚 Le cours Sécurité web complet, gratuit et interactif

Distinguer la sécurité réelle de la sécurité perçue, c'est le cœur du cours Sécurité web : OWASP, authentification, CSRF, contrôle d'accès, avec des labos d'attaque exécutables dans la page.

Commentaires (0)