Anti-spam de formulaires sans captcha : 6 filtres qui attrapent 99 % du déchet

Honeypot, regex URL, longueur téléphone, ratio non-cyrillique, rate-limit, log. Six filtres sans captcha bloquent presque tout le spam sans gêner l'utilisateur.

Un captcha c’est une taxe sur la conversion. Selon différentes mesures, il coûte 3 à 8 % des envois réels, surtout sur mobile. Dès le début du projet j’ai décidé sans captcha. Le spam est coupé par six filtres simples en PHP, chacun fermant sa classe d’attaques. Au total - environ 60 lignes de code, zéro dépendance et zéro requête vers des services tiers.

En bref

  • 6 filtres : honeypot, regex URL, longueur téléphone, ratio non-cyrillique, rate-limit, log.
  • Je n’utilise pas de captcha - il coûte 3-8 % de conversion et lie le formulaire à un service tiers.
  • Tous les filtres tournent côté serveur (pas de JavaScript, pas de dépendances). Les bots qui passent sans moteur JS ne contournent pas.
  • Le spam baisse de 99 % dans mes métriques, les leads légitimes passent sans perte.

Filtre 1 : Honeypot

Ajout d’un champ caché dans le formulaire - généralement avec un nom comme website ou url, ceux sur lesquels les bots réagissent le plus. Caché via CSS (pas type="hidden", parce que les bots ignorent souvent les champs par type).

<input type="text" name="website" tabindex="-1" autocomplete="off"
       style="position:absolute;left:-9999px;width:0;height:0;visibility:hidden">

Vérification côté serveur en premier :

if (!empty($_POST['website'])) {
    log_spam('honeypot', $_POST);
    exit; // Jeter silencieusement, sans message
}

Important - jeter silencieusement. Ne pas répondre ‘vous êtes un bot’. Les bots apprennent : si votre serveur réagit à un honeypot pris en flagrant par une redirection ou erreur, ils comprennent que le champ est vérifié. Mieux vaut faire comme si l’envoi était passé : renvoyer 200 OK et la page ‘merci’ normale. Le bot pense que ça a marché et passe à la cible suivante.

Dans mes logs, le honeypot attrape 60-80 % de toutes les attaques sur formulaires. Le filtre le plus simple et le plus efficace.

Filtre 2 : Regex sur les URLs en commentaire

La majeure partie du spam restant, ce sont des posts avec liens vers casino, répliques, escort. Ils mettent l’URL directement dans le champ commentaire ou message.

$message = $_POST['message'] ?? '';
if (preg_match('#https?://|www\.|\.com/|\.ru/|\.net/|\.shop/#i', $message)) {
    log_spam('url_in_message', $_POST);
    exit;
}

Ça bloque le spam ‘normal’ avec liens. Il arrive qu’un utilisateur légitime veuille indiquer un lien - genre ‘voici notre site example.com’. Solution - ne pas utiliser le champ ‘message’ comme champ universel. Chez moi il y a un champ séparé ‘site’ (optionnel, passe par son propre validateur d’URL), et dans message les URLs sont interdites par règle.

Si vos clients mentionnent souvent des sites - assouplissez le filtre à http:// et https:// explicites, pas chaque point avec un domaine. Alors mentionner example.com passe, mais un vrai lien https://casino.xyz est coupé.

Filtre 3 : Longueur du téléphone

C’est du bon sens. Un téléphone russe a au minimum 10 chiffres (sans indicatif) ou 11 (avec). Les bots mettent généralement un random de 4-7 chiffres, ou même 16-20 (imitant un format international avec déchet en plus).

$phone = preg_replace('/\D/', '', $_POST['phone'] ?? '');
$len = strlen($phone);
if ($len < 10 || $len > 11) {
    log_spam('phone_length', $_POST);
    exit;
}

preg_replace('/\D/', '') enlève tout sauf les chiffres - espaces, tirets, parenthèses. Après ça vous comptez la longueur. Russie et CEI quasiment toujours 10-11 chiffres. Clients internationaux - étendez à 15 (E.164 max).

Optionnel : vérifier que le numéro commence par 7 ou 8 (pour ceux affectés par la substitution du premier chiffre) :

if ($len === 11 && !in_array($phone[0], ['7', '8'])) {
    log_spam('phone_country', $_POST);
    exit;
}

Filtre 4 : Non-cyrillique dans les commentaires

Site russophone, vrais clients écrivent en russe. Les bots avec spam anglais sont filtrés par le ratio caractères cyrilliques sur total.

$message = $_POST['message'] ?? '';
$len = mb_strlen($message);
if ($len > 5) {
    // Compter caractères cyrilliques
    preg_match_all('/[\p{Cyrillic}]/u', $message, $matches);
    $cyr = count($matches[0]);
    if ($cyr / $len < 0.3) {
        log_spam('not_cyrillic', $_POST);
        exit;
    }
}

30 % de cyrillique est un seuil qui marche. Un commentaire bilingue genre ‘nettoyage appartement, area 80 m², 2 bathrooms’ passe. Pur spam anglais est coupé. Pur russe passe naturellement.

Pour les sites anglophones on inverse - on vérifie le ratio latin. Pour les bilingues on peut soit désactiver, soit monter la barre à 80 % - mais alors plus de spam passe.

Filtre 5 : Rate-limit 5 requêtes par heure par IP

Un utilisateur n’envoie pas dix formulaires par minute. Si une IP fait plus de 5 requêtes par heure - c’est un attaquant qui teste les filtres ou pousse du spam de masse.

Sans cluster Redis sur hébergement mutualisé, le plus simple est de stocker en fichier ou MySQL. Chez moi en MySQL :

CREATE TABLE rate_limit (
    ip VARCHAR(45) NOT NULL,
    ts INT UNSIGNED NOT NULL,
    KEY idx_ip_ts (ip, ts)
);

Avant de traiter l’envoi :

$ip = $_SERVER['REMOTE_ADDR'];
$hour_ago = time() - 3600;

$pdo->prepare("DELETE FROM rate_limit WHERE ts < ?")->execute([$hour_ago]);

$stmt = $pdo->prepare("SELECT COUNT(*) FROM rate_limit WHERE ip = ? AND ts > ?");
$stmt->execute([$ip, $hour_ago]);
$count = $stmt->fetchColumn();

if ($count >= 5) {
    log_spam('rate_limit', $_POST);
    exit;
}

$pdo->prepare("INSERT INTO rate_limit (ip, ts) VALUES (?, ?)")->execute([$ip, time()]);

Nettoyage de la table - cron quotidien, pour qu’elle ne grossisse pas indéfiniment.

Le piège - les utilisateurs derrière NAT (réseau d’entreprise, opérateur mobile). Si 10 personnes d’un même bureau envoient des formulaires - le rate-limit sur l’IP du bureau se déclenche. 5 par heure laisse généralement la marge : les utilisateurs réels n’envoient pas plus de 1-2, il reste du tampon. Si vous craignez - montez à 10-20 par heure. Surtout ne le retirez pas, sinon une attaque de masse depuis une IP couchera votre boîte mail.

Filtre 6 : Log dans un fichier protégé

Tous les envois rejetés sont écrits dans spam_log.txt. Pas dans le log général du serveur, pas en BDD (la BDD est plus chère en écriture), mais un simple fichier texte :

function log_spam(string $reason, array $data): void {
    $entry = date('c') . ' | ' . $reason . ' | IP ' . $_SERVER['REMOTE_ADDR'] . ' | ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n";
    file_put_contents(__DIR__ . '/../spam_log.txt', $entry, FILE_APPEND | LOCK_EX);
}

Le log doit absolument être protégé au niveau du serveur web - sinon n’importe qui le télécharge et voit vos patterns de spam. Pour Apache dans le .htaccess racine :

<Files "spam_log.txt">
    Require all denied
</Files>

Pour Nginx - dans la config de location :

location = /spam_log.txt {
    deny all;
}

Ce que le log apporte. Une fois par semaine je l’ouvre et je regarde. On voit quels filtres se déclenchent le plus : si 90 % c’est honeypot, les autres ne tirent presque pas parce que les bots ne vont pas plus loin. On voit les patterns d’attaque : un afflux soudain depuis une plage d’IP, un flux de commentaires chinois avec des liens vers le même sujet. À partir de ces patterns on peut affiner les filtres ou bannir temporairement une plage dans .htaccess.

Après 30 jours je fais une rotation spam_log.txtspam_log.txt.bak, un nouveau fichier vide est créé. Je garde l’ancien une période pour l’analyse, puis je le supprime.

Ordre des filtres

Point important - vérifier dans le bon ordre, du moins cher au plus cher. Pour ne pas faire une requête SQL rate-limit si le honeypot a déjà flagué l’envoi comme déchet.

// 1. Honeypot - le moins cher
if (!empty($_POST['website'])) { log_spam('honeypot', $_POST); exit; }

// 2. Regex URL - aussi pas cher (in-memory)
if (preg_match('#https?://#i', $_POST['message'] ?? '')) { ... }

// 3. Longueur du téléphone - pas cher
if (strlen(preg_replace('/\D/', '', $_POST['phone'] ?? '')) < 10) { ... }

// 4. Non-cyrillique - un peu plus cher à cause du regex UTF-8
preg_match_all('/[\p{Cyrillic}]/u', $_POST['message'] ?? '', $m);
// ...

// 5. Rate-limit - le plus cher, demande du SQL
$pdo->prepare("SELECT COUNT(*) FROM rate_limit WHERE ...");

Ça donne la charge minimale - la majorité des attaques sont coupées sur les deux premiers filtres, avant que le serveur ne touche la BDD.

Ce que ces filtres ne ferment pas

Six filtres coupent le spam automatique. Ils ne ferment pas :

  • Les attaques ciblées par un humain. Si quelqu’un s’assoit et remplit votre formulaire à la main avec des offres concurrentes - les filtres laissent passer. Mais c’est rare et coûteux pour l’attaquant. Un ou deux cas par an - règlement en 5 minutes.
  • Le spam de lead-gen via CRM. Il arrive qu’un prestataire ‘lead generation’ enregistre en masse sur des sites de connaissances de notre client avec des données bidon. Le honeypot ne tire pas (humain qui remplit), mais le rate-limit attrape.
  • DDoS sur un endpoint POST. C’est niveau serveur, pas applicatif. Protégé par nginx limit_req ou CDN. Beget CDN le fait au niveau edge gratuitement.

Au total, les filtres couvrent 99 % du trafic automatisé qui attaque un site B2B services normal. Suffisant pour un business qui n’est pas cible d’une attaque ciblée.

Le cas complet sur le lancement d’un site fait maison sur mutualisé avec PHP 8.4 - dans l’article ‘50 jours de SEO en B2B nettoyage’. L’anti-spam de formulaires fait partie du travail de la première semaine.

À lire aussi : CLS 0,377 → 0,002 en une journée et OPcache sur hébergement mutualisé - d’autres corrections rapides avec gros impact.

Questions fréquentes

Pourquoi je suis contre reCAPTCHA et autres solutions toutes faites
Trois raisons. Un - la conversion. Selon diverses études reCAPTCHA v2 coûte 3-8 % des envois réels, surtout sur mobile et sur vieux appareils. C'est de l'argent. Deux - dépendance externe. reCAPTCHA c'est une requête vers google.com, qui depuis la Russie est bloquée et filtrée par DPI. Si un utilisateur est sur VPN d'entreprise ou son FAI filtre - le formulaire ne s'envoie tout simplement pas, sans erreur claire. Trois - confidentialité. reCAPTCHA collecte une empreinte de l'appareil et l'envoie à Google, risque supplémentaire au regard de la 152-FZ russe pour un site russe.
C'est quoi un honeypot et comment ça marche ?
Un champ dans le formulaire, caché à l'utilisateur via CSS mais visible pour parsers et bots. Les bots remplissent généralement tous les champs - text inputs, urls, emails - parce qu'ils ne distinguent pas visible de caché. Si le champ est rempli - c'est un bot, on jette l'envoi. Les utilisateurs réels ne voient pas le champ et ne le remplissent pas. Un honeypot simple kill 60-80 % du spam automatisé sans aucun retour à l'utilisateur.
Le rate-limit ne bloque pas les vrais clients ?
Avec les bons seuils, non. Chez moi c'est 5 envois par heure depuis une même IP. Scénario réel : une personne envoie, se trompe de numéro, corrige, renvoie. Ça fait 2. Peut-être un troisième avec une précision en commentaire. Un cinquième envoi depuis la même IP en une heure, c'est déjà suspect. Si vous avez un bureau avec NAT et plusieurs personnes qui envoient vraiment des formulaires 10 fois - relevez la limite à 20/heure.
Pourquoi un filtre non-cyrillique sur les commentaires ?
Le site est russophone, audience Russie et CEI. Les vrais clients écrivent en russe. Les bots spammeurs publient souvent en anglais ou chinois avec des liens vers casino, pharma, escort. Le filtre marche ainsi : si le commentaire fait plus de 5 caractères et que moins de 30 % sont cyrilliques - on jette. Ça ne bloque pas les commentaires bilingues (genre 'besoin de nettoyer appartement, area 80 m²') mais coupe le spam pur anglais. Pour les sites anglophones vous inversez le filtre.
Où stocker le log de spam et pourquoi le regarder ?
Dans un fichier dédié `spam_log.txt`, protégé via `.htaccess`. Pourquoi le regarder. Premier - mesurer l'efficacité des filtres : combien et quel type d'attaques arrivent. Deuxième - repérer les faux positifs : un lead légitime atterri dans le log, le filtre demande à être ajusté. Troisième - blacklist de mots-clés. À posteriori vous voyez des patterns (mentions constantes de 'crypto', 'replica', 'escort') et vous pouvez les ajouter en blacklist. Le log ne doit pas être publié - dans `.htaccess` la règle `<Files spam_log.txt> Require all denied </Files>`.