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_addsur le gestionnaire de connexions : le champ n'existe plus, le filtre est ignore sans erreur ;request_headers_to_addsur 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_proxy50 : methodeCONNECT, URI absolue (http://tiers/) ou chemin commence par//. Le site est utilise comme relais.backdoor_attempt45 : noms de coquilles connues (shell.php,wso.php,c99.php,xmlrpc.php...). La fausse piste est rare, le poids est haut.generic_bf40 : cinq soumissions ou plus sur le meme chemin dans la fenetre (POST, PUT, PATCH, DELETE). Compte par adresse et par chemin.crawl_non_statics35 : quarante chemins distincts demandes dans la fenetre. Un visiteur en parcourt quelques-uns ; un balayage demande tout.admin_probing25 : interfaces d'administration d'outils courants (/wp-admin,/phpmyadmin,/manager/html,/server-status...).technology_probing20 : reconnaissance de version (.git/config,composer.json,actuator/env,phpinfo.php...). Poids bas parce qu'unpackage-lock.jsonpeut 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 :
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, etdenyavec 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.
- 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.
- Exemptions (voir plus bas) : la requete est marquee comme non challengeable, pour cette requete seulement.
- 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.
- Issue :
drop,captchaoupow, dans cet ordre de severite croissante. Le defaut estpow, et c'est aussi le repli quand le scoring est muet ou indisponible. Le filtre applique l'issue, il ne la choisit pas. - Score du temps 2, sur soumission. Total recalcule avec les signaux de
l'empreinte. Au-dessus du seuil :
RejectSubmission. En dessous : clearance. - 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-afteren 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.jsonet/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
impossiblea 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.
fpscannerest 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¶
path_prefixest un prefixe, pas un remplacement. Le service recoit/v1/verdict<chemin d'origine>. Une egalite sur/v1/verdictrend un 404, et le filtre transforme ce 404 en refus : le site entier tombe en 404.- Envoy n'ajoute pas
x-envoy-original-path. Contrairement a ce que laissent croire plusieurs exemples, seulshost,content-lengthetx-envoy-internalarrivent. Le chemin d'origine se reconstitue en retirant le prefixe, rien d'autre. - 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. Etprefix: ""(tout autoriser) est perdu au passage : il faut nommer les en-tetes un par un. 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,
healthzreponddegraded: 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¶
- Le taux de faux positifs par signal, sur nos propres journaux. Sans lui, les poids ne sont que des hypotheses informees.
- Le cout du score en charge, et pas seulement a vide.
- 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¶
- Detection de robots, CrowdSec (alpha) : https://docs.crowdsec.net/docs/next/appsec/bot_detection/intro/
- Contrat de challenge : https://docs.crowdsec.net/docs/next/appsec/bot_detection/challenge_protocol/
- Hub CrowdSec, licence MIT : https://github.com/crowdsecurity/hub
- Copies de reference :
docs/reference/crowdsec/
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, raisonallow_entry.deny: la requete est refusee, raisondeny_entry.
Une duree de 0 ne perime pas. En observation, une entree deny est journalisee
comme telle mais laisse passer, comme le reste.