Aller au contenu

Scoring et decision au bord

Ce document definit le moteur de scoring : quels signaux, quel poids, quel seuil, et dans quel ordre on decide. Il s'inspire du fonctionnement de la detection de robots d'AppSec CrowdSec, dont les fichiers de reference sont copies dans docs/reference/crowdsec/.

A lire avec docs/proxy.md pour le filtre qui applique la decision, et docs/infrastructure.md pour ce sur quoi tout tourne.

Le principe repris

CrowdSec separe deux choses, et c'est la bonne facon de faire :

  • une configuration qui note : chaque signal declenche ajoute des points, avec une raison lisible (cdp=100, utc_timezone=15) ;
  • une configuration qui decide : un seuil unique, sur le total.

Les trois postures livrees different par le seul seuil : 75 points pour la posture equilibree, 45 pour la stricte, 100 pour la permissive. Le bareme ne bouge pas. On peut donc durcir ou adoucir la politique sans reevaluer chaque poids, et surtout on peut expliquer apres coup pourquoi un client a ete refuse.

On reprend cette structure a l'identique. Ce qui change, c'est la provenance des signaux, parce que notre pile n'a pas de moteur WAF devant l'application.

Notre decoupage en trois temps

La difference de fond avec CrowdSec : leur page de challenge collecte l'empreinte du navigateur et la renvoie avec la preuve de travail, dans la meme requete. Nous n'avons que la preuve de travail. Le score se construit donc en deux endroits et a deux moments.

Temps 1, avant tout challenge. Signaux vus par le bord seul : adresse, empreinte TLS, en-tetes, historique. Le score est forcement incomplet, et c'est normal : c'est lui qui decide si on challenge, et a quelle difficulte.

Temps 2, sur soumission de la preuve. Un client qui a resolu le challenge a execute notre page. Si cette page collecte une empreinte, elle arrive ici. Le score se complete, et c'est ce score complet qui decide d'accorder ou de refuser la clearance.

Temps 3, en continu. Les scenarios comportementaux observent la duree : trop de challenges servis sans soumission, trop de soumissions pour une meme adresse. Ils produisent des blocages, pas des challenges.

Le bareme

Les poids viennent du bareme CrowdSec, verifies dans docs/reference/crowdsec/appsec-configs/appsec-bot-challenge-scoring.yaml. La colonne de droite est notre propre lecture : ce que le signal vaut quand on ne peut pas le verifier cryptographiquement.

Points Signaux Pourquoi ce poids Forgeable cote client
100 cdp, webdriver, webdriver_writable, selenium, playwright, webdriver_iframe, webdriver_worker, bot_user_agent Un cadre d'automatisation pilote le navigateur, ou le client s'annonce lui-meme comme robot. Aucune raison honnete de voir ca sur un client qui vient de resoudre un challenge. Non, si l'empreinte est signee et chiffree par nos soins. Oui pour bot_user_agent, qui se lit dans l'en-tete.
50 headless_screen_resolution, missing_chrome_object, impossible_memory, inconsistent_etsl, mismatch_webgl_worker, mismatch_platform_iframe, mismatch_platform_worker Environnements sans affichage et incoherences entre contextes. Difficiles a masquer, mais chacun a une queue de vrais clients. Non.
30 platform_mismatch, gpu_mismatch, high_cpu_count Suspect, mais atteignable par des configurations reelles : machines virtuelles, bureaux distants. Partiellement.
15 utc_timezone, ua_mobile, accept_language Assez courant chez de vrais visiteurs pour qu'un seul de ces signaux ne veuille rien dire. Les serveurs, les VPN et les outils de confidentialite produisent tous un fuseau UTC. Oui pour accept_language, qui vient d'un en-tete.
5 swiftshader_renderer, mismatch_languages, timezone_country N'a de sens qu'en accumulation. timezone_country se declenche sur tout voyageur et tout utilisateur de VPN. Partiellement.

La reputation d'adresse, traitee a part parce qu'elle ne se mesure pas comme les autres signaux : voir docs/ip-reputation.md pour les listes, leur licence et le niveau de confiance a leur accorder.

Trois signaux propres a notre pile, absents du bareme d'origine, a peser quand on aura des mesures :

  • Appartenance de l'adresse a un hebergeur plutot qu'a un acces resideniel. Poids retenu 12, desormais servi par la liste d'ASN : voir « La reputation du reseau ». C'est un bon marqueur, et il est infalsifiable, mais il ne doit jamais decider seul.
  • Empreinte TLS (JA4), desormais dans le score et en temps reel : voir « L'empreinte TLS » plus bas pour les poids, la chaine de transport et les mesures.
  • Absence de cookies ou de sous-ressources sur un client qui se declare navigateur. Poids pressenti 15.

L'empreinte TLS

Le ClientHello porte ce qu'aucun en-tete ne dit : la version de TLS, la liste des suites de chiffrement, celle des extensions, le protocole annonce. Le terminateur TLS en tire JA4, une chaine de la forme t13d1517h2_8daaf6152771_e6dcd7ae0a9e. Un agent utilisateur se falsifie en une ligne ; une poignee de main, beaucoup moins.

Les poids, et sur quoi ils sont calibres

Points Signal Lecture
60 ja4_forged Empreinte presente mais illisible : quelqu'un a ecrit l'en-tete.
60 ja4_openssl_impersonation Agent utilisateur de navigateur, mais 25 suites de chiffrement ou plus : c'est une bibliotheque generique, pas un navigateur.
40 ja4_churn Quatre empreintes distinctes ou plus pour une seule adresse dans l'heure.
30 ja4_no_alpn Aucun protocole annonce. Un navigateur en annonce toujours un.
25 ja4_thin_hello Agent utilisateur de navigateur et onze extensions ou moins.
20 ja4_sni_ip Pas de nom d'hote annonce, seulement une adresse.

Les seuils ne sont pas sortis d'une intuition : ils viennent des journaux du banc, releves le 20 octobre 2026 sur le trafic reel du domaine.

Client Empreinte Suites Extensions
Chrome 131, Windows t13d1517h2_8daaf6152771_e6dcd7ae0a9e 15 17
Firefox, Linux t13d1713h1_ab0a1bf427ad_ecd0401ec68b 17 13
curl 8.14 t13d3013h2_1d37bd780c83_8537cf56674e 30 13
python-urllib t13d3014h1_1d37bd780c83_5ae85263eb3b 30 14
fasthttp t13d131100_f57a46bbacb6_ab7e3b40a677 13 11

La separation est nette : les navigateurs offrent 15 a 19 suites, les bibliotheques generiques une trentaine, et fasthttp n'annonce aucun protocole. C'est ce qui rend le signal defendable sans liste de clients connus a tenir a jour, qui serait toujours en retard d'une version.

La chaine, et pourquoi elle est tordue

L'empreinte vit dans l'etat du filtre TLS, au niveau de l'ecouteur. Le decideur est un filtre HTTP insere en tete de chaine : un en-tete pose par un filtre ordinaire arriverait apres lui, et il ne verrait rien.

Trois chemins ont ete essayes, deux echouent avec Envoy 1.38 :

  • request_headers_to_add sur le gestionnaire de connexions : le champ n'existe plus, le filtre est ignore sans erreur ;
  • request_headers_to_add sur l'action de route : refuse, unknown field in envoy.config.route.v3.RouteAction ;
  • le filtre envoy.filters.http.header_mutation, insere avant le decideur : accepte, et %TLS_JA4_FINGERPRINT% y est substitue.

C'est donc un filtre de mutation d'en-tete qui porte l'empreinte, sous le nom x-exoledge-ja4. L'action est OVERWRITE_IF_EXISTS_OR_ADD : un client qui enverrait cet en-tete lui-meme est ecrase. Verifie sur le banc, l'en-tete force est ignore et l'empreinte reelle gagne.

Mesure sur le banc

Trace du decideur, trois requetes depuis la meme adresse :

  • curl qui se declare Chrome, sans cookie : score=75, captcha, no_cookies,ja4_openssl_impersonation ;
  • curl honnete, sans cookie : score=65, pow, automated_ua,no_cookies ;
  • empreinte forcee dans l'en-tete : score=65, l'empreinte reelle est retenue.

Mentir coute donc plus cher que s'annoncer pour ce qu'on est, et un navigateur reel ne paie rien pour son empreinte.

Ce qui reste ouvert

Le changement d'empreinte se mesure par adresse, pas par identite. Une ferme de robots derriere une seule adresse est vue ; un robot qui change d'adresse a chaque requete ne l'est pas. Le compteur vit une heure dans Valkey.

JA4H, l'empreinte HTTP, completerait bien JA4 : elle compare l'ordre des en-tetes et leur casse, ce qu'un client qui imite un navigateur rate souvent. Elle est au catalogue du pilotage, pas dans le code.

Aucune empreinte quand la connexion n'est pas terminee ici. Un site servi en TCP brut n'a pas de ClientHello : le signal disparait, et le score doit rester juste sans lui, ce qui est le cas.

La reconnaissance et le balayage

Six fonctions du catalogue etaient declarees sans rien derriere. Elles vivent maintenant dans policy-core, dans un module de motifs separe du bareme, et elles se declenchent avant le corps de la requete.

Le principe : un chemin qui n'existe dans aucun site normal est une intention, pas un accident. Un site peut legitimement servir /admin ; il ne sert pas /.git/config.

Poids ajoutes au score :

  • open_proxy 50 : methode CONNECT, URI absolue (http://tiers/) ou chemin commence par //. Le site est utilise comme relais.
  • backdoor_attempt 45 : noms de coquilles connues (shell.php, wso.php, c99.php, xmlrpc.php...). La fausse piste est rare, le poids est haut.
  • generic_bf 40 : cinq soumissions ou plus sur le meme chemin dans la fenetre (POST, PUT, PATCH, DELETE). Compte par adresse et par chemin.
  • crawl_non_statics 35 : quarante chemins distincts demandes dans la fenetre. Un visiteur en parcourt quelques-uns ; un balayage demande tout.
  • admin_probing 25 : interfaces d'administration d'outils courants (/wp-admin, /phpmyadmin, /manager/html, /server-status...).
  • technology_probing 20 : reconnaissance de version (.git/config, composer.json, actuator/env, phpinfo.php...). Poids bas parce qu'un package-lock.json peut etre servi par choix sur un site de developpeurs.

Deux compteurs nourrissent les deux derniers : path_hits, incremente seulement sur les methodes qui soumettent, et distinct_paths, un ensemble borne par la fenetre. Ils vivent dans le magasin partage, donc une adresse ne remet pas son compteur a zero en changeant de noeud.

Un chemin d'interface (/api/, /console, /login, /assets/) reste exempte de defi : une requete XHR ne peut pas resoudre une preuve de travail. Elle reste soumise au refus et au score.

Ce qui n'est pas fait, et pourquoi. Le cache HTTP n'est pas implemente. Un cache vit au niveau de l'ecouteur, pas de la route : en mode bord partage, il ne peut pas se regler site par site, et l'activer dans la console ne ferait rien. La console l'affiche comme non appliquee plutot que de laisser croire le contraire.

La reputation du reseau

Une adresse ne dit pas tout : le reseau qui l'annonce non plus, mais il en dit plus que rien. Un serveur loue en deux minutes chez un hebergeur a bas prix n'a pas la meme clientele qu'une box residentielle, et certains reseaux n'ont qu'un metier : heberger ce que les autres refusent.

Le decideur ne fait pas de requete BGP au moment de decider : la traduction ASN vers prefixes est faite hors ligne par tools/asn_lists.py, qui interroge les prefixes annonces (RIPEstat) pour une selection d'ASN ecrite a la main, et ecrit quatre fichiers de plages dans policy/lists/. Le bord compare donc des adresses, comme pour toutes les autres listes.

Categorie Poids Pourquoi
bulletproof_hosting refus reseaux dont le metier est l'hebergement pare-balles : le refus est la seule reponse
vpn 40 l'anonymat se paie : n'importe qui peut emprunter l'adresse
tor 45 plus fort que le VPN : le noeud de sortie est partage par des milliers d'inconnus
proxy 40 idem VPN, avec un service souvent ouvert a tous
bad_asn 15 cloud ou hebergeur connu comme source d'automatisation
hosted 12 hebergement en libre-service : indice faible, jamais decisif seul

Deux choix meritent d'etre justifies.

Pourquoi si peu pour un hebergement. Beaucoup d'entreprises ont leurs propres serveurs, et une part notable du trafic legitime vient d'un cloud. Un hebergement n'est pas une faute : a douze points, il ne fait basculer aucune decision seul, mais il suffit, avec une absence de cookies et un agent automatise, a faire monter le score au-dessus du seuil.

Pourquoi un refus pour l'hebergement pare-balles. La liste est courte et verifiee : ce sont des reseaux dont l'activite declaree est l'hebergement de botnets, de fermes de RDP ou de faux sites. Les traiter comme les autres reviendrait a financer leur modele.

Ce que la reputation d'ASN ne fait pas. Elle ne bloque pas un gros fournisseur. L'exemple de Yandex est instructif : un meme groupe annonce un ASN pour son moteur de recherche et un autre pour son cloud, et les deux n'ont pas la meme clientele. Bloquer l'ASN du cloud deplace l'attaque ailleurs sans toucher aux robots d'indexation, qui sont de toute facon exemptes par leur plage editeur. C'est pourquoi Yandex.Cloud est a quinze points et Yandex Search absent de la liste : une reputation de reseau est une presomption, pas une preuve.

Les prefixes sont regeneres a la main, quand la selection change :

python3 tools/asn_lists.py --out policy/lists

Les postures et les seuils

On reprend les trois seuils, sans les modifier, parce qu'ils sont coherents avec le bareme : les poids sont dans {5, 15, 30, 50, 100}, donc un seuil a 45 s'atteint avec un signal modere et un faible (30 + 15), et un seuil a 75 avec deux moderes et un faible (30 + 30 + 15).

Posture Rejet a partir de Effet
permissive 100 Seule l'automatisation quasi certaine est refusee. Moins de faux positifs, plus de robots qui passent.
equilibree 75 Le defaut. Attrape les cadres d'automatisation et les navigateurs sans affichage sans agir sur les signaux faibles.
stricte 45 Assume de refuser quelques vrais visiteurs pour ne laisser passer aucun robot.

Regle de conduite : une seule posture active a la fois, et on ne change de posture qu'avec des journaux a l'appui. Passer en stricte pour compenser un scoring incomplet est le meilleur moyen de faire fuir les vrais utilisateurs, qui ne se plaignent pas : ils partent.

Ce qui decide qu'un site est attaque

Le mode « sous attaque » ne repose plus sur un debit fixe. Un site qui recoit trois requetes par minute et un site qui en recoit trois mille n'ont pas la meme definition d'une attaque, et un seuil unique se trompe sur les deux.

Par defaut, la reference est le trafic du site lui-meme : la moyenne horaire des trente derniers jours, a la meme heure. Comparer a la moyenne du jour punirait un site de bureau le lundi matin et ne verrait rien le samedi. Le niveau monte quand le debit depasse 110 % de cette reference, et passe en attaque au double. Un plancher de cinq requetes par minute evite qu'un site tres calme bascule pour quatre visites.

Le debit ne suffit jamais a lui seul : la part hostile reste exigee (20 % puis 50 %). Un article partage qui multiplie le trafic par cinq ne doit pas imposer un defi a tout le monde ; une promotion n'est pas une attaque.

L'operateur peut remplacer cette comparaison par des seuils fixes, site par site : mode: static avec elevated_rate, attacked_rate, elevated_share, attacked_share et attacked_ratio. Les deux modes se lisent et s'ecrivent par GET et POST /v1/pressure/config, et la console les expose.

L'historique vit dans le magasin partage : un compteur par site et par heure, gardes trente jours. Il coute un aller-retour de plus par requete, et c'est le prix de la memoire du trafic ordinaire.

D'ou vient une adresse

Le decideur embarque une table reseau construite hors ligne par tools/ip_intel.py a partir des bases DB-IP Lite (CC BY 4.0). Pour chaque adresse, il sait l'ASN, l'organisation, le pays, et une nature de reseau : bulletproof, tor, vpn, proxy, hosting, bad_asn, mobile, residential ou unknown.

La nature suit un ordre de confiance : ce qui a ete decide a la main (les listes d'ASN) passe avant ce que le nom de l'operateur suggere. Un hebergement pare-balles dont le nom contient « Telecom » reste un hebergement pare-balles.

Ce contexte n'entre pas dans le score par defaut. Il sert a repondre a la question d'avant la decision : « cette adresse vient de chez qui, et dans quel pays ». Les regles par site s'appuieront dessus plus tard.

Attribution : les bases de geolocalisation sont fournies par DB-IP (https://db-ip.com), sous licence Creative Commons Attribution 4.0. Les listes Tor viennent du projet Tor (https://check.torproject.org/torbulkexitlist), qui publie aussi un miroir en DNSBL chez dan.me.uk (https://www.dan.me.uk/dnsbl) ; la liste des sorties est preferee a celle de tous les noeuds, parce qu'un relais benevole n'est pas un client anonyme.

Les trois issues, et d'ou elles viennent

Deux projets libres ont ete lus pour cadrer cette partie : go-away (auto-heberge, Go, detection d'abus) et Altcha (captcha libre, MIT).

go-away expose neuf actions : PASS, PROXY, DENY, BLOCK, DROP, CHALLENGE, CHECK, CONTEXT, NONE. Quatre sont terminantes et tranchent la requete, deux continuent l'evaluation, et deux distinguent ce que la plupart des produits confondent : CHALLENGE termine, CHECK laisse continuer les regles apres le challenge. Leur echelle de severite recoupe celle de CrowdSec : allow < unknown < captcha < challenge < ban.

Altcha tient une these differente et complementaire : la preuve de travail est invisible par defaut, et l'escalade vers un controle interactif n'arrive que si le risque le justifie. Leur noyau libre ne fait que la preuve de travail ; l'escalade adaptative est la partie payante. Deux details techniques valent d'etre retenus : le mode deterministe signe les parametres et ne demande au serveur qu'une seule derivation pour verifier, ce qui est exactement notre schema de challenge signe ; et leur choix d'algorithme (PBKDF2 par defaut, Argon2id quand on veut user la memoire de l'attaquant) est une piste pour plus tard, quand le cout du SHA-256 deviendra trop faible.

Ce qu'on garde de ces deux lectures, pour notre pile :

  • Trois issues seulement : drop, captcha, pow. Un filtre en phase d'authentification ne sait pas proxifier ni reecrire vers un autre serveur, et deny avec page se confond avec nos deux pages. Ajouter des actions qu'on ne peut pas executer ne produirait que de l'ambiguite.
  • Le defaut est le plus doux : preuve de travail. C'est aussi le repli quand le scoring est muet, ce qui evite qu'une panne du scoring transforme un filtrage en refus general.
  • L'echelle de severite est stable : pass < captcha < pow < drop. On ne melange jamais les niveaux entre deux configurations, sinon une decision « plus severe » peut en realite laisser passer.
  • L'issue est decidee ailleurs, appliquee ici. Le filtre ne note pas, il execute. C'est ce qui rend la decision testable et modifiable sans redeployer le module.

Ce qu'on ne prend pas, pour l'instant : check (challenger puis continuer l'evaluation), parce qu'il suppose une chaine de regles apres le challenge que nous n'avons pas encore. Et context, qui demande de modifier la requete vers l'amont, hors du perimetre du filtre.

L'ordre d'evaluation

L'ordre n'est pas cosmetique, il determine ce qu'une requete coute.

  1. Clearance valide : passage immediat. Ce chemin ne lit que l'adresse du client et la signature. Aucun score n'est calcule, aucune requete n'est envoyee au moteur. C'est la garantie de cout sur le trafic legitime.
  2. Exemptions (voir plus bas) : la requete est marquee comme non challengeable, pour cette requete seulement.
  3. Score du temps 1, sur les signaux du bord. Sous le seuil bas, on laisse passer sans challenge. Entre les seuils, on challenge a la difficulte de base. Au-dessus du seuil de rejet, on peut refuser sans challenge.
  4. Issue : drop, captcha ou pow, dans cet ordre de severite croissante. Le defaut est pow, et c'est aussi le repli quand le scoring est muet ou indisponible. Le filtre applique l'issue, il ne la choisit pas.
  5. Score du temps 2, sur soumission. Total recalcule avec les signaux de l'empreinte. Au-dessus du seuil : RejectSubmission. En dessous : clearance.
  6. Scenarios comportementaux en parallele, sur la duree.

Deux details qui evitent des bugs penibles : le score est par requete et repart de zero, il ne persiste pas d'une requete a l'autre, et le rejet est terminal, aucune regle posterieure ne peut l'annuler.

Un refus doit dire pourquoi

Un 403 muet ne se diagnostique pas : on croit a un bug du site, on cherche au mauvais endroit, et on finit par accuser le navigateur. Toute reponse de refus porte donc deux en-tetes :

  • x-verdict-reason: banned (ou l'identifiant de la raison qui a fait refuser : scanner, cve_attacker, ...) ;
  • retry-after en secondes quand la raison est une fermeture temporaire.

x-verdict-score reste present. Un score de 100 accompagne toujours un refus, mais ce n'est pas une note du navigateur : c'est le maximum de l'echelle, pose quand la decision est certaine. Un Firefox propre avec ses cookies vaut 0 a 15. Quand on lit 100, il faut lire la raison avant de lire le score.

Les appels d'interface ne se defient pas

Une page se defie : le navigateur execute le JavaScript, resout le calcul, et repart avec un laissez-passer. Un appel fetch ne le peut pas. Il n'y a pas de page a remplacer, pas de script a executer, et la reponse attendue est du JSON.

Defier /api/ produisait trois degats en une seule mecanique : l'application cassait, chaque appel comptait comme un defi non resolu, et trois appels suffisaient a fermer l'adresse d'un utilisateur parfaitement legitime. La console elle-meme tombait dans le piege : son premier chargement demandait la session, le second les statistiques, le troisieme les journaux.

Les chemins /api/, /console/, /login, /assets/ et /favicon sont donc hors du defi. Ils ne sont pas hors de la protection : le refus sur reputation certaine, la fermeture des adresses et le pare-feu d'application continuent de s'appliquer. Le score reste calcule et journalise, il ne declenche simplement plus de defi.

Les exemptions

Quatre familles, reprises du hub, avec la meme regle : une exemption ne vaut que pour la requete en cours, sauf si on l'accorde explicitement pour la session.

Etat au 20 octobre 2026 : la famille 2 est en place, les trois autres ne le sont pas. Concretement, un robot d'editeur dont l'adresse appartient a une plage publiee passe sans rien payer ; un robot qui se nomme depuis une autre adresse est traite plus durement qu'un automate qui s'annonce pour ce qu'il est.

1. Adresses internes et privees. 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, ::1. A poser en premier, ca evite de challenger ses propres sondes.

2. Robots verifies par le reseau. Jamais par l'agent utilisateur seul. La regle du hub est explicite : l'adresse doit appartenir aux plages publiees par l'editeur, ou passer une verification de DNS inverse confirmee. Un agent usurpe sur une adresse qui n'appartient pas a l'editeur n'est pas reconnu et suit le chemin normal. Le controle est fail-closed : adresse illisible, echec DNS ou fichier inconnu donnent « non reconnu ».

Les familles livrees, a activer selon le besoin : - moteurs de recherche : googlebot, bingbot, applebot, amazonbot, yandex, baidu, yahoo, sogou, qwant, babbar, duckduckbot, ahrefs, semrush ; - robots d'IA : gptbot, openai-searchbot, openai-chatgpt-user, perplexitybot ; - reseaux sociaux : meta, discord, telegram, twitterbot, pinterest ; - supervision : uptimerobot, cookiebot, datadog, pagerduty.

Notre pile fait le premier temps : la plage d'adresses, verifiee sur x-envoy-external-address ou, a defaut, sur la derniere valeur de la chaine x-forwarded-for, celle que notre saut a ajoutee. Le DNS inverse confirme n'est pas fait : il coute une resolution par adresse inconnue, et les listes publiees suffisent tant qu'on ne sert pas de sites a fort trafic.

Les trois listes sont dans policy/lists/allow/, generees depuis les sources publiees par les editeurs : 198 prefixes de robots, 195 de prestataires de paiement, 9 de forges. Elles arrivent au decideur par le meme volume que les listes d'accusation, et c'est le nom du fichier qui decide du sort : cve.txt accuse, crawlers-search.txt innocente.

listes: /lists/crawlers-search.txt -> Robots (exemption), 198 lignes
listes: /lists/payment-providers.txt -> Payment (exemption), 195 lignes

Une adresse exemptee rend remediation: pass, score 0, raison exempt_robots ou exempt_paiement. Un robot nomme dont l'adresse n'est pas dans la liste rend crawler_impersonation, 60 points, et ne cumule pas avec automated_ua : mentir coute plus cher que s'annoncer.

Un defaut trouve en chemin, et il valait la peine : le volume de ConfigMap apparait dans le repertoire sous trois formes (crawler.txt, ..data/..., ..2026_.../...). Le lecteur prenait les trois, rangeait les deux dernières sous un nom inconnu et ignorait le fichier. Un filtre sur les entrees qui commencent par un point suffit. Le symptome etait un journal qui disait « ignoree (nom inconnu) » pour six fichiers bien presents.

3. Chemins connus. Repris tels quels du hub, parce que ce sont des chemins de fait :

  • fiches robot : /robots.txt, /ads.txt, /app-ads.txt, /sitemap.xml, /security.txt, /humans.txt, /apple-app-site-association, /.well-known/* ;
  • flux : /feed, /rss, /atom.xml, /feed.xml, /rss.xml, /index.xml, /feed.atom, et les prefixes /feed/ et /rss/ ;
  • ressources statiques : css, js, mjs, map, images, polices, medias, plus /manifest.json et /browserconfig.xml ;
  • interfaces de programme : /api, /graphql, prefixes /api/, /wp-json/, /graphql/, /rest/, /v1/, /v2/, /oauth/, /_next/data/ ;
  • webhooks : prefixe /webhooks/.

4. Exemptions d'exploitant. Une liste tenue a la main, pour les partenaires et les sondes internes. C'est la seule qui doit etre revue periodiquement, parce qu'elle vieillit mal.

Deux portees, a ne pas confondre :

  • exemption par requete : pas de cookie, rien ne persiste. Pour les chemins et les robots verifies.
  • clearance accordee d'office : un cookie est emis pour toute la duree de vie prevue. Pour un partenaire identifie, jamais pour un chemin.

La fermeture automatique

Un captcha servi est un fait. Une adresse qui en collectionne trois sans jamais resoudre ne merite plus de page : elle est fermee pour quinze minutes.

Le mecanisme tient en deux cles dans Valkey, strike:<adresse> et ban:<adresse>. Le compteur de refus vit aussi longtemps que la fermeture, donc il ne s'accumule pas indefiniment. Une adresse fermee est refusee sans calcul : remediation: drop, raison banned, ce qui economise l'aller-retour.

Trois routes pour l'exploitant, sur le decideur :

GET    /v1/bans?ip=203.0.113.5     etat : fermee ou non, refus en cours
POST   /v1/bans                    {"ip": "...", "secs": 3600} fermeture a la main
DELETE /v1/bans?ip=203.0.113.5     levee immediate

Verifie sur le banc : trois captchas depuis la meme adresse, puis banned: true, puis 403 sur la requete suivante, puis 200 apres levee.

L'identite de l'appelant, et le piege

Une fermeture qui se contourne avec un en-tete ne vaut rien. Le decideur lisait la premiere valeur de x-forwarded-for, celle que le client ecrit lui-meme : un X-Forwarded-For: 10.9.9.9 suffisait a changer d'identite, donc a echapper a une fermeture, remettre ses compteurs a zero et sortir de la reputation. Constate sur le banc, corrige le meme jour.

L'ordre de lecture est maintenant : x-envoy-external-address, l'adresse lue sur la prise par Envoy, puis x-real-ip, puis la derniere valeur de x-forwarded-for, celle que notre propre saut a ajoutee. Deux tests couvrent les deux chemins.

Les scenarios comportementaux

Deux scenarios suffisent, et leurs valeurs sont copiees du hub :

Scenario Declencheur Parametres Effet
challenges servis sans soumission une meme adresse recoit la page de challenge et ne soumet jamais 10 evenements, fuite 20 s, oubli apres 1 min blocage
soumissions repetees une meme adresse soumet encore et encore, signature d'un solveur automatise 5 evenements, fuite 20 s, oubli apres 1 min blocage

Le second est le plus important pour nous : la preuve de travail rend la soumission couteuse, elle ne la rend pas impossible. Un attaquant qui resout correctement peut soumettre en boucle, et c'est exactement ce que ce scenario attrape. Le premier attrape l'inverse : celui qui demande sans jamais payer.

Un rejet isole ne bloque personne. C'est un signal, pas une decision. Seuls les scenarios creent des blocages, apres franchissement d'un debit dans le temps.

Regles de securite de l'integration

Elles viennent de la lecture du contrat de challenge de CrowdSec, et chacune correspond a une erreur deja payee par quelqu'un :

Les en-tetes internes sont ecrases par le bord, sans exception. CrowdSec transmet l'adresse du client dans X-Crowdsec-Appsec-Ip et precise que, si cet en-tete est lu depuis le client, c'est lui qui decide des exemptions et des regles par pays. Chez nous, c'est x-challenge-action, qui porte l'issue choisie par le scoring. L'ancien en-tete x-challenge-difficulty, lu sur la requete du client et ramene au plancher de difficulte, a ete retire du filtre : un client pouvait y choisir sa propre difficulte.

Les cookies partent en en-tetes repetes, jamais joints par une virgule. Les analyseurs de cookies de certains clients ne savent pas lire une liste jointe.

La politique de securite de contenu de la page de challenge remplace celle du site. Un navigateur applique toutes les politiques qu'il recoit : une CSP de site posee a cote de celle du challenge bloque le script en ligne et le worker du challenge. La page de challenge impose donc la sienne, et le bord doit ecraser, pas ajouter.

Le corps de la soumission est transmis, sans quoi le challenge ne peut pas etre valide.

Une panne du composant qui sert le challenge ne doit jamais accorder un passage non verifie. Deux modes a distinguer : le module de challenge peut echouer ouvert, pour ne pas couper le service ; la validation d'un jeton ne doit jamais echouer ouvert, sinon une signature illisible vaut autorisation.

La difficulte se pilote par paliers nommes, pas par un nombre envoye par le client. CrowdSec utilise disabled (0 bit), low (18), medium (20, defaut), high (22), impossible (256, rejet cote serveur). Nos bornes actuelles vont de 12 a 26 bits avec 18 par defaut, ce qui correspond a leur low. Si le scoring decide de la difficulte, il doit choisir un palier dans cette echelle, et le palier doit etre applique cote bord, pas accepte du client.

Ce que nous ne reprenons pas

  • L'obfuscation du JavaScript et les cles par epoque. Notre clearance est auto-portante et signee : il n'y a pas de cle de signature a livrer au navigateur, donc rien a obfusquer de ce cote.
  • Le palier impossible a 256 bits. Un rejet explicite est plus honnete qu'un challenge insoluble, et coute moins de calcul au client comme au bord.
  • La bibliotheque d'empreinte telle quelle. fpscanner est libre, mais l'integrer demande de servir trois fichiers JavaScript, de gerer leur cache et de signer l'empreinte. C'est un chantier a part, a decider separement.

La mise en service

Le decideur tourne sur la passerelle. Image rg.fr-par.scw.cloud/exoledge/policy-service:0.1.5, namespace edge, un replica. Le binaire est statique et l'image ne contient que lui : il n'y a pas d'image de base a maintenir.

policy/                      le service
tofu/edge/policy.tf          secret, listes, deploiement, service, filtre
tools/oci_static_image.py    fabrication de l'image, sans demon Docker

Envoy l'interroge en ext_authz avant tout autre filtre HTTP. Le verdict revient par en-tete : x-challenge-action porte l'issue (pow ou captcha), un 403 porte le refus. Le filtre de preuve de travail lit cet en-tete et sert la page correspondante ; sans verdict, il defie par defaut.

Quatre pieges, tous constates a l'execution

  1. path_prefix est un prefixe, pas un remplacement. Le service recoit /v1/verdict<chemin d'origine>. Une egalite sur /v1/verdict rend un 404, et le filtre transforme ce 404 en refus : le site entier tombe en 404.
  2. Envoy n'ajoute pas x-envoy-original-path. Contrairement a ce que laissent croire plusieurs exemples, seuls host, content-length et x-envoy-internal arrivent. Le chemin d'origine se reconstitue en retirant le prefixe, rien d'autre.
  3. Aucun en-tete du client n'est transmis par defaut. Sans authorization_request.allowed_headers, le decideur ne voit ni l'agent, ni le cookie, ni l'adresse. Et prefix: "" (tout autoriser) est perdu au passage : il faut nommer les en-tetes un par un.
  4. INSERT_FIRST. Un filtre insere par defaut juste avant le routeur passe apres les filtres de la passerelle : le challenge ne verrait jamais l'action demandee.

Verifier

kubectl -n edge port-forward svc/policy 8080:8080
curl -s localhost:8080/healthz
curl -s "localhost:8080/v1/verdict?ip=198.51.100.9&ua=curl%2F8"

# par la passerelle : la sanction change avec la posture
curl -A 'curl/8' -o /dev/null -w '%{size_download}\n' https://demo.guillaume.ink/

La page de preuve de travail fait 8761 octets, celle du captcha 3512, un refus zero. En posture balanced, un client sans liste connue recoit la preuve de travail ; en posture strict, le meme client recoit le captcha. C'est cette difference qui prouve que le verdict pilote la sanction.

Limites

  • Magasin memoire. Sans Valkey, healthz repond degraded : les compteurs et les clearances ne survivent pas au redemarrage et ne se partagent pas entre replicas.
  • Listes statiques, montees depuis un ConfigMap : pas de rechargement a chaud, il faut redeployer.
  • Un seul replica, et il est sur le chemin de chaque requete. Sa panne est couverte par failure_mode_allow : on laisse passer sans verdict.
  • Cout mesure : 2 a 4 ms par requete (42 a 49 ms au total, filtres compris).

Ce qui reste a mesurer

  1. Le taux de faux positifs par signal, sur nos propres journaux. Sans lui, les poids ne sont que des hypotheses informees.
  2. Le cout du score en charge, et pas seulement a vide.
  3. La separation reelle des signaux : un signal qui ne separe pas les populations ne doit pas etre dans le bareme, meme avec un petit poids.

References

Les documents de la console

La coquille de la console est un fichier statique, sans secret : tout ce qu'elle affiche vient de /api/, qui exige une session. La defier ne protege rien et coute cher : un operateur qui ouvre sa console en ligne de commande, ou dont le navigateur n'execute pas le calcul, prend un defi non resolu par page, et trois pages suffisent a fermer son adresse.

Les documents /console ne se defient donc plus. Ils restent soumis au refus, a la reputation et a la fermeture : sortir du defi n'est pas sortir de la protection. Les pages ordinaires, elles, continuent d'etre defiees.

Le mode observation

Le mode est tenu par le decideur, pas par la console : platform:mode dans Valkey, lu a chaque verdict, expose par GET /v1/mode et change par POST /v1/mode. La console ne fait que le lire et le demander.

En observation, le verdict est calcule, journalise et rendu, mais rien n'a de consequence : ni refus, ni defi, ni strike, ni fermeture. Le WAF suit le meme mode : la politique poussee passe toutes les familles en detection. Une seule bascule, deux organes qui disent la meme chose.

Un refus qui n'a pas eu lieu se lit quand meme : x-observed: 1 accompagne la reponse, et le corps du verdict porte observed: true. C'est ce qui permet de chiffrer ce que le filtrage aurait fait avant de l'allumer.

Le mode « sous attaque »

Un site qui recoit soudain beaucoup de trafic hostile n'a pas besoin de regles supplementaires : il a besoin qu'on demande plus a chaque client. C'est le mode « I'm under attack » d'un reverse proxy, avec trois differences : il se mesure par site, il s'applique tout de suite, et il redescend tout seul.

La mesure

Par nom d'hote, le decideur compte deux choses sur une fenetre glissante de deux minutes : le nombre de requetes, et le nombre de requetes jugees hostiles. Une requete est hostile quand le verdict est un refus, un captcha, ou un score de 25 ou plus ; une adresse fermee qui continue de frapper compte aussi, c'est le signal le plus net.

Le debit seul ne suffit pas : un site populaire n'est pas attaque. C'est la part de trafic hostile qui fait la difference.

  • tendu : au moins 25 requetes par minute, dont 20 % d'hostiles ;
  • sous attaque : au moins 75 requetes par minute, dont 50 % d'hostiles ;
  • normal : le reste.

Un niveau atteint est tenu trois minutes : sans cela, une attaque qui respire ferait osciller les seuils a chaque minute, et le site repasserait en normal au milieu de l'attaque.

Les compteurs vivent dans Valkey (pr:<site>:<minute>), donc plusieurs instances du decideur voient la meme pression ; la lecture est mise en cache cinq secondes par site, ce qui evite trois lectures par requete.

Ce que le niveau change

Le niveau est une entree de policy-core, pas une politique a part :

  • le seuil de posture est multiplie par 0,7 en tendu, 0,45 sous attaque : plus de clients passent au captcha au lieu d'une preuve de travail ;
  • la difficulte demandee gagne 2 bits en tendu, 4 sous attaque. Le filtre de preuve de travail prend cette valeur comme plancher : la preuve devient plus chere immediatement, sans attendre que le volume de challenges fasse monter l'echelle adaptative ;
  • le laissez-passer dure 30 minutes en normal, 15 en tendu, 5 sous attaque. Un client qui a paye une fois est donc redefie plus souvent ;
  • une fenetre de passage ouverte (pass:<ip>) n'est plus honoree sous attaque : c'est precisement le laissez-passer qu'une ferme cherche a obtenir avant de frapper.

La levee de la pression ne demande aucune intervention : les compteurs expirent, le maintien tombe, le niveau redescend.

Le geste de l'operateur

La mesure peut se tromper, ou arriver trop tard. POST /v1/pressure force un niveau pour un site pendant une duree choisie (auto rend la main a la mesure). La console expose le meme geste sur l'ecran Anti-bot, avec la carte « Pression des sites » : niveau, debit, part hostile, motif, et un bouton « Sous attaque ». Le forcage est journalise dans le journal d'administration.

Ce que le mode ne fait pas

Il ne bloque personne en plus : il ne touche ni aux fermetures, ni aux listes, ni au WAF. Il ne s'applique pas aux chemins d'interface (/api/, /console, /login, /assets/, /favicon) : les defier casserait la console sans rien proteger.

Les entrees manuelles

Une adresse, une decision, une duree, une note : GET, POST et DELETE /v1/entries. Elles passent avant tout le reste, y compris avant le score et avant une fermeture.

  • allow : la requete passe, score 0, raison allow_entry.
  • deny : la requete est refusee, raison deny_entry.

Une duree de 0 ne perime pas. En observation, une entree deny est journalisee comme telle mais laisse passer, comme le reste.