Pourquoi le spam a battu mon formulaire de contact à débit limité
Pourquoi le spam passe-t-il un formulaire de contact protégé par une limite de débit ? Parce qu’une limite de débit compte les requêtes par adresse, et que rien en elle ne demande qui les a envoyées. Le 30 octobre et le 2 novembre, deux scanners de vulnérabilités ont parcouru mon site, chargé comme n’importe quel visiteur la page d’accueil qui porte le formulaire, récupéré un jeton CSRF valide, puis soumis le formulaire jusqu’à passer à autre chose ou être bloqués. Le premier jour, 101 soumissions sont passées, le second 31, chacune un 200 de l’application, qu’elle renvoie pour un envoi réussi comme pour un envoi échoué : ces chiffres sont donc le maximum d’e-mails qu’elles ont pu produire.12 Le limiteur de mon application, réglé sur cinq envois par minute et par adresse, a refusé 830 et 31 de leurs envois et laissé passer 101 et 31 envois d’e-mails, soit 47 et 9 de plus qu’un limiteur exact à ce réglage n’en aurait permis.3 La limite de débit de l’edge ne s’est déclenchée sur aucun des deux scanners : je l’ai créée environ 12 minutes après le dernier envoi de formulaire de la première vague, et les envois du second scanner sont arrivés par deux centres de données Cloudflare, qui tiennent des compteurs séparés. Ce partage est mon explication la plus solide du second silence : il concorde avec les totaux par minute, qui ne peuvent pas le prouver.456 Sur le site de mon studio, un champ honeypot (un champ piège invisible) a intercepté 42 des 52 soumissions en huit jours et laissé passer les 10 dont les expéditeurs avaient laissé le honeypot vide.78 L’edge de ce site avait bien une règle capable de demander si une personne est là, un managed challenge sur le chemin du formulaire, mais elle ne se déclenche que sur une chaîne user-agent listée, et les deux scanners envoyaient la chaîne d’un navigateur ; un second challenge, conditionné à un threat score élevé, ne pouvait jamais se déclencher, car Cloudflare fixe désormais ce score à zéro, si bien que rien ne posait la question à chaque soumission.910411 Depuis le matin du 3 novembre, Turnstile la pose, sur les deux formulaires, à chaque soumission susceptible d’atteindre ma boîte de réception.12
En bref
- Deux scanners, une boîte de réception. Une adresse a envoyé à mon site 76 429 requêtes réparties sur 53 tranches d’une minute le 30 octobre ; une autre en a envoyé 10 907 sur 22 le 2 novembre. Les deux ont parcouru tout le site, les deux ont envoyé des chaînes d’injection SQL dans les chemins et dans l’en-tête User-Agent, et les deux ont soumis le formulaire de contact : 1 065 fois le premier jour et 500 fois le second.131412
- Un jeton CSRF valide prouve seulement qu’un client détenait un jeton émis par mon site. Chaque envoi des deux jours en portait un, car mes jetons sont sans état et tout client qui récupère la page en obtient un. La vérification CSRF a refusé quatre envois en deux vagues.1516
- Mon propre limiteur fuyait. Il lit un compteur dans Cloudflare KV, le compare, puis le relit et écrit le compte plus un dans une requête distincte ; une seconde lecture qui échoue écrit un, et un appel KV qui échoue se rabat sur un compte gardé dans la mémoire d’un seul processus. N’importe lequel de ces chemins peut laisser passer plus de cinq envois, et les journaux ne peuvent pas dire lequel l’a fait : 18 envois sont partis en une minute pour une limite de cinq.171
- La limite de débit de l’edge compte par adresse et par centre de données. Le 2 novembre, les 85 envois de formulaire du scanner entre 18:56 et 19:05:00 UTC, les minutes qui encadrent le blocage, sont passés par Amsterdam et Londres, 44 et 41, et la minute la plus chargée s’est répartie 18 contre 14 entre les deux. Ce partage est la raison la plus probable du silence de la règle, même si des totaux par minute ne peuvent pas le prouver. Une sonde que j’ai lancée le lendemain matin a prouvé que la règle se déclenche et que Cloudflare le journalise.518
- Les blocages étaient des blocages d’adresse, et tous deux sont arrivés trop tard pour tenir les e-mails à l’écart. Le 30 octobre, j’ai bloqué l’adresse à la main 3,9 minutes après m’en être aperçu, environ 50 minutes après son premier envoi de formulaire. Le 2 novembre, mon moniteur l’a bloquée 8 minutes après la première minute du scan. Aucun envoi, ni l’un ni l’autre jour, n’est survenu après la minute du blocage : le 30 octobre, le dernier est arrivé plus de 12 minutes avant le blocage, et le 2 novembre, 26 sont arrivés dans les minutes qui le précédaient et 5 dans la minute même du blocage, que des données par minute ne permettent pas de placer avant ou après la seconde du blocage.101912
- Le honeypot attrape les bots qui remplissent tous les champs. Sur le site de mon studio, il a répondu à 42 envois sur 52 comme s’ils avaient réussi, sans rien enregistrer ; d’après l’heure de leur arrivée, toutes les soumissions venant d’un nœud de sortie Tor en faisaient partie. Les 10 enregistrées, et donc 10 e-mails au plus, venaient d’expéditeurs qui avaient laissé le honeypot vide.720
- Ce qui a changé. Les deux formulaires portent désormais un widget Cloudflare Turnstile en mode managed, et le serveur n’enregistre ni n’envoie rien tant que l’API siteverify de Cloudflare n’a pas accepté le jeton de la soumission ; il refuse un jeton absent, trop long ou invérifiable. Turnstile exécute des challenges dans le navigateur du visiteur et traite des signaux tels que l’adresse IP, l’empreinte TLS et le user agent ; Cloudflare indique qu’il ne touche pas aux saisies des formulaires. Il n’arrête pas une personne qui tape du spam à la main, et il ne remplace pas les limites de débit, qui bornent toujours le volume.122122
1. La question : « c’est toi ? »
Le 2 novembre, ma boîte de réception s’est remplie de messages du formulaire de contact, et ma première question, posée à la session d’agent qui fait tourner mon infrastructure, a été de savoir si ma propre automatisation les envoyait. Ce n’était pas le cas. C’était un scanner, via le formulaire de contact de ce site, et le formulaire du site de mon studio, 941apps.com, recevait de son côté un lent goutte-à-goutte, même si rien n’en a atteint la boîte ce jour-là.238 Les trois flux de courrier, les alertes de mon moniteur et les deux formulaires, partent d’une même adresse sur mon propre domaine de messagerie : dans la boîte de réception, ils se ressemblaient.23
Les objets ont trahi l’affaire. Le gestionnaire de contact de ce site m’envoie chaque soumission avec l’objet « Contact Form: » suivi de ce que l’expéditeur a tapé dans les champs de nom, si bien que la charge utile d’un scanner devient une ligne d’objet.2423 Le formulaire du site du studio enregistre chaque message dans une base de données, puis l’envoie par e-mail à la même boîte.25
La question que j’ai posée en premier était la mauvaise, et la bonne a demandé une journée de requêtes : lequel des contrôles de ces formulaires aurait pu distinguer un scanner d’une personne, et pourquoi chacun a-t-il laissé passer le reste ? Il y avait cinq types de contrôle. Sur ce site, un pare-feu d’edge avec des règles personnalisées, une limite de débit à l’edge, un jeton CSRF et un limiteur de débit dans l’application. Sur le site du studio, le même genre de règles d’edge, une limite de débit à l’edge et un limiteur applicatif à lui, qui n’a refusé aucun des envois de formulaire observés, plus un champ honeypot.9257 La suite de cet article reprend les deux vagues et le lent goutte-à-goutte un par un, puis les cinq couches une par une, avec pour chacune le texte de la règle ou le comportement du code.
D’où viennent les chiffres
Chaque chiffre ci-dessous provient des analyses de requêtes et des événements de pare-feu de Cloudflare pour les deux zones, interrogés le 3 novembre sur des fenêtres déjà closes, plus la table des messages du site du studio et le journal de mon propre moniteur.16 Un script a transformé ces résultats de requêtes sauvegardés en un registre de quelques centaines de lignes, chacune accompagnée de la règle qui l’a produite, et l’article tire ses chiffres de ce registre et de son addendum, et de nulle part ailleurs. Quand mes notes du jour contredisent le registre, c’est le registre qui l’emporte : j’ai écrit les notes du 30 octobre quelques minutes après la vague, alors que Cloudflare ingérait encore ses dernières minutes, et elles sous-estiment.1626 Cloudflare conserve ces jeux de données « at least 31 days of data on every plan » (au moins 31 jours de données quelle que soit l’offre), si bien que j’ai pu compter une vague après coup, mais pas indéfiniment.27
Deux sortes de chiffres appellent une mise en garde. Les événements de pare-feu sont un échantillon : je les utilise pour savoir quelle règle a agi et quand, et les jeux de données de requêtes pour savoir combien.16 Et un envoi de formulaire auquel ce site a répondu 200 est un envoi d’e-mail, ou un échec auquel le gestionnaire répond aussi 200 (il a deux chemins d’échec de ce type), pas un e-mail remis, et je n’ai pas consulté le journal de mon fournisseur de messagerie. Chaque nombre d’e-mails de cet article est donc un plafond.2428
À quoi ressemblait la normale
Avant les vagues, le formulaire était calme. Sur les huit jours couverts par les requêtes, trois adresses autres que les deux scanners ont soumis le formulaire avec succès, chacune exactement deux fois, une adresse par jour les 27, 28 et 29 octobre.29 Je ne sais pas si c’étaient des personnes. Deux envois depuis une même adresse correspondent à un double clic, et ils correspondent aussi à un bot qui réessaie. Cette incertitude est tout le problème en miniature : rien dans les journaux ni dans les contrôles ne pouvait trancher.
2. Première vague : le 30 octobre, bloquée à la main
La première vague venait d’une adresse située sur un réseau que le registre Internet régional enregistre au nom de BULLIONET FBW NETWORKS SAS, immatriculé en France.30 Sa première requête est tombée dans la minute de 17:59 UTC, 10:59 heure d’été du Pacifique, et sa dernière dans la minute de 18:51, 11:51 PDT : 53 tranches d’une minute et 76 429 requêtes vers ce site. Deux requêtes indépendantes, l’une minute par minute pour l’adresse, l’autre balayant toutes les zones de mon compte, concordent sur ce total, et l’adresse n’a rien envoyé ni avant ni après.13
La forme du scan
Le formulaire n’en était qu’une petite partie. Avant le blocage, l’adresse a envoyé en moyenne 1 389,7 requêtes par minute, avec un pic de 3 219 dans la minute de 18:36, et elle a parcouru tout le site : au moins 2 881 chemins distincts, une borne inférieure, car la requête par chemin a atteint sa limite de lignes.1331 Sur l’ensemble du scan, les résultats se répartissent ainsi :13
| Résultat | Requêtes | Part |
|---|---|---|
| Servies (2xx, 3xx) | 49 513 | 64,8 % |
| Refusées par l’application (4xx), dont 10 019 par la barrière à agents du site | 18 990 | 24,8 % |
| Erreurs d’origine (520) | 2 339 | 3,1 % |
| Bloquées à l’edge (403) | 5 587 | 7,3 % |
Les 7,3 % de l’edge sont presque entièrement le blocage : 5 234 de ses 5 587 refus sont survenus après la minute du blocage.13 Pendant les cinquante minutes qui l’ont précédé, seule la règle des chemins d’exploit a agi. Cette règle a fonctionné dès la première minute, avec un premier événement de pare-feu à 17:59:53 UTC, mais elle détecte des sous-chaînes de chemin comme .php et /wp-, et ce scan demandait surtout de vraies pages.109
Ce qui en faisait un scan plutôt qu’une simple exploration, c’est ce qu’il glissait dans les requêtes. Presque tout le trafic, au moins 69 322 requêtes, utilisait un unique user agent Chrome 141 ordinaire pour Windows. Au moins 2 755 autres prétendaient être Googlebot, et la règle des bots vérifiés de l’edge ne les a pas traitées comme Google.32 Au moins 3 154 requêtes portaient une charge utile d’injection SQL dans l’en-tête User-Agent lui-même, sous au moins 396 chaînes distinctes, et au moins 2 680 requêtes en portaient une dans le chemin, sur au moins 810 chemins distincts.3231 Ces charges utiles appartiennent à des familles documentées. PG_SLEEP(15), waitfor delay '0:0:15' et XOR(if(now()=sysdate(),sleep(15),0)) sont des sondes temporelles : l’aide-mémoire de PortSwigger donne la syntaxe de temporisation pour Oracle, Microsoft SQL Server, PostgreSQL et MySQL, et les définitions de charges utiles de sqlmap contiennent les mêmes fonctions.3334 PortSwigger explique pourquoi elles fonctionnent : « As SQL queries are normally processed synchronously by the application, delaying the execution of a SQL query also delays the HTTP response. » (Comme l’application traite normalement les requêtes SQL de façon synchrone, retarder l’exécution d’une requête SQL retarde aussi la réponse HTTP.)35 Une réponse lente isolée ne prouve pas grand-chose ; un délai d’environ 15 secondes qui revient chaque fois que la charge utile le demande, et pas sur des requêtes témoins qui en sont dépourvues, est l’indice d’une injection possible.
L’application de dessin de ce site a un point de terminaison de partage, et le scanner l’a trouvé. Il a envoyé 320 POST vers le chemin de partage, seule adresse à y poster durant les huit jours couverts par les requêtes, et le point de terminaison les a tous refusés sans rien enregistrer. Autour d’eux, il a posté vers des variantes du chemin intégrant la charge utile, comme /paint/share'||DBMS_PIPE.RECEIVE_MESSAGE(CHR(98)||CHR(98)||CHR(98),15)||' et /paint/(select 198766*667891 from DUAL), auxquelles le site a répondu 404.31 L’adresse a aussi touché une autre zone de mon compte, avec 387 GET vers kiradex.app, tous servis en 200, et aucune autre.13 Ma lecture est qu’elle a suivi des liens de ce site vers celui-là ; je n’ai pas vérifié ces liens.
Le catalogue des menaces automatisées de l’OWASP a un nom pour cette activité : OAT-014, Vulnerability Scanning, « Crawl and fuzz application to identify weaknesses and possible vulnerabilities. » (Explorer et fuzzer l’application pour en repérer les faiblesses et les vulnérabilités possibles.)36 Le formulaire de contact n’était qu’une entrée de plus à fuzzer.
Le formulaire, minute par minute
L’adresse a soumis le formulaire de contact 1 065 fois, et chaque envoi a atteint l’application ; l’edge n’en a arrêté aucun.1 Le premier envoi et le premier e-mail sont tombés dans la minute de 18:00 UTC, 11:00 PDT. Voici chaque minute où un envoi de formulaire a atteint l’application, selon le statut renvoyé par l’application :1
| UTC | PDT | Envoyés (200) | Refus CSRF (403) | Champ manquant (422) | Refus du limiteur (429) |
|---|---|---|---|---|---|
| 18:00 | 11:00 | 8 | |||
| 18:01 | 11:01 | 12 | 133 | ||
| 18:02 | 11:02 | 9 | 148 | ||
| 18:03 | 11:03 | 4 | 4 | 200 | |
| 18:04 | 11:04 | 9 | 89 | ||
| 18:05 | 11:05 | 16 | 164 | ||
| 18:06 | 11:06 | 18 | 85 | ||
| 18:07 | 11:07 | 7 | 11 | ||
| 18:08 | 11:08 | 2 | |||
| 18:09 | 11:09 | 4 | |||
| 18:10 | 11:10 | 9 | |||
| 18:11 | 11:11 | 1 | |||
| 18:12 | 11:12 | 1 | |||
| 18:35 | 11:35 | 1 | |||
| 18:36 | 11:36 | 1 | |||
| 18:37 | 11:37 | 129 |
Les totaux : 101 envoyés, 830 refusés par le limiteur de l’application, 130 refusés faute d’un champ obligatoire, et 4 refusés par la vérification CSRF, tous les quatre dans la minute de 18:03.1 Les 429 venaient de l’application. La règle de limite de débit de l’edge n’existait pas encore, il n’existe aucun événement de pare-feu de limitation de débit pour l’adresse, et là où Cloudflare a enregistré le statut de l’origine pour ces envois, le 429 venait de l’origine.1037 Les 422 venaient de la validation de formulaire de FastAPI, qui s’exécute avant le limiteur, si bien qu’ils n’ont jamais consommé de place dans le limiteur ; j’ai vérifié l’ordre avec une application FastAPI de dix lignes, où un envoi sans champ obligatoire a renvoyé 422 sans que l’enveloppe soit jamais appelée.38 La rafale de 129 à 18:37 ressemble à un scanner qui fuzze les champs du formulaire en les supprimant.
Lisez la colonne « Envoyés » à la lumière du limiteur. Le limiteur autorise cinq envois par adresse et par minute d’horloge, et dans huit de ces minutes, plus de cinq sont partis, 18 à 18:06.13 La section 4 explique comment.
La découverte et le blocage
Je m’en suis aperçu à 11:46 PDT et j’ai noté que quelque chose frappait encore et encore le formulaire de contact du site.26 À ce moment, le premier envoi de formulaire datait d’environ 46 minutes, et la vague sur le formulaire elle-même s’était terminée à 11:37 PDT.1 J’ai ajouté l’adresse à la main à la liste de blocage au niveau du compte, que la première règle personnalisée de la zone consulte déjà, et le premier événement de cette règle est arrivé à 18:49:53 UTC, 11:49:53 PDT.10 Le blocage est survenu 3,9 minutes après que je m’en suis aperçu, environ 50 minutes après le premier envoi de formulaire, et 50,9 minutes après la première minute du scan.113 Les 101 envois d’e-mails étaient tous partis avant lui ; le dernier envoi de formulaire à atteindre l’application est arrivé environ 12 minutes avant le blocage.1 Après la minute du blocage, l’adresse a envoyé 5 234 requêtes de plus, et l’edge les a toutes refusées.13
À la même seconde, 18:49:53 UTC, j’ai créé l’unique règle de limitation de débit de la zone : les POST de formulaire vers les chemins de contact et de partage de dessin, 3 par 10 secondes et par adresse, avec un blocage de 10 secondes.9 À 18:52:38 UTC, je l’ai testée depuis ma propre machine. Selon mes notes de ce test, six envois espacés d’une seconde, avec un user agent de navigateur et sans jeton CSRF valide, ont « returned 403 403 403 429 429 429 (the app’s CSRF rejection, then the edge’s rate limit) » (renvoyé 403 403 403 429 429 429 : le refus CSRF de l’application, puis la limite de débit de l’edge), et de simples envois curl n’ont jamais atteint la règle, parce qu’une règle personnalisée existante leur a servi un managed challenge à la place.26 Mes notes disent que les envois ne portaient pas de jeton, mais un envoi dépourvu de ce champ reçoit un 422 avant l’exécution du gestionnaire, comme l’a montré ma sonde du 3 novembre : les 403 signifient donc un jeton présent mais invalide.18 Les événements de pare-feu concordent : trois événements de limitation de débit depuis mon adresse de test, le premier à cette seconde, et 24 managed challenges pour mes envois curl.39 Aucun de mes envois de test ne figure dans les chiffres ci-dessus.
Mon moniteur ne tournait pas ce jour-là. Je l’ai réarmé le 31 octobre, et son détecteur de trafic est arrivé après cette vague.19 Le blocage du 30 octobre, c’était une personne qui lisait sa boîte de réception.
30 octobre : 76 429 requêtes depuis une seule adresse, par minute ; jusqu’à ce que je bloque l’adresse à la main, l’edge n’en a refusé presque aucune.1310
3. Deuxième vague : le 2 novembre, bloquée par mon moniteur
La seconde adresse appartient à NovoServe B.V., un hébergeur établi aux Pays-Bas.40 Elle a envoyé deux GET à 07:49 UTC, 23:49 heure normale du Pacifique le 1er novembre, puis plus rien pendant 11,1 heures. Le scan proprement dit a couru de la minute de 18:56 UTC à celle de 19:17, de 10:56 à 11:17 PST : 22 tranches d’une minute et 10 907 requêtes, ou 10 909 avec les deux GET matinaux. Quatre requêtes indépendantes concordent sur le total.14
Cela ressemblait au même outil. Elle a utilisé la même chaîne user-agent Chrome 141 pour Windows que l’adresse du 30 octobre pour 9 725 requêtes, la même chaîne Googlebot pour 942, les mêmes familles d’injection, et elle a touché les deux mêmes zones et aucune autre, avec 368 GET vers kiradex.app.414214 Deux adresses sur deux réseaux dans deux pays ne prouvent rien quant à leur opérateur ; j’en reste donc à « cela ressemblait ».
La chronologie, dans les deux fuseaux horaires
| UTC | PST | Ce qui s’est passé |
|---|---|---|
| 18:56 | 10:56 | Le scan commence : 29 requêtes dans la minute.1419 |
| 18:57:00 | 10:57:00 | Une exécution de mon moniteur démarre. Sa fenêtre se ferme à 18:57:22 : il voit donc environ une minute du scan, et ne trouve rien.19 |
| 18:57 | 10:57 | La minute la plus chargée avant le blocage, 637 requêtes. La règle des chemins d’exploit de l’edge commence à bloquer à 18:57:11.144 |
| 18:58 | 10:58 | Les trois premiers envois de formulaire. L’application envoie les trois.2 |
| 19:00 | 11:00 | Quatre envois de formulaire de plus, tous les quatre envoyés.2 |
| 19:02 | 11:02 | 32 envois de formulaire atteignent l’application : 9 envoyés, 23 refusés par le limiteur de l’application.2 |
| 19:02:46 | 11:02:46 | L’exécution suivante du moniteur démarre.19 |
| 19:03 | 11:03 | 10 envoyés, 8 refusés.2 |
| 19:03:53 | 11:03:53 | Mon moniteur lève un constat critique d’inondation de requêtes : 1 914 requêtes depuis l’adresse.19 |
| 19:04:01 | 11:04:01 | Mon moniteur ajoute l’adresse à la liste de blocage du compte.19 |
| 19:04:03 | 11:04:03 | Le premier événement de la règle de liste de blocage.4 |
| 19:04 | 11:04 | Les cinq derniers envois et 22 envois de formulaire refusés à l’edge, tous dans la minute même du blocage ; la tranche d’une minute ne dit pas lequel est venu en premier.2 |
| 19:07 | 11:07 | La minute la plus chargée du scan, 1 107 requêtes, toutes bloquées.14 |
| 19:17 | 11:17 | La dernière minute, 300 requêtes, toutes bloquées.14 |
Les huit minutes de mon moniteur
Mon moniteur a levé son premier constat 7,9 minutes après la première minute du scan, et le blocage a pris effet 8,0 minutes après elle, soit 6,8 minutes après le premier événement de pare-feu.194 À ce moment, 26 envois d’e-mails étaient partis dans les minutes précédant le blocage, et les 5 derniers sont tombés dans la minute même du blocage, que les données par minute ne permettent pas de placer avant ou après la seconde du blocage ; aucun n’est survenu dans les minutes suivantes.2 Huit minutes, c’est rapide pour un blocage et lent pour une boîte de réception.
L’exécution lancée à 10:57:00 PST a eu le scan sous les yeux pendant environ une minute, et dans sa fenêtre l’adresse a fait entre 29 et 666 requêtes ; le chiffre haut compte toute la minute de 18:57, dont la fenêtre n’a vu que 22 secondes. Le détecteur n’a journalisé aucun constat.19 L’exécution suivante, à 11:02:46 PST, a repéré la vague et l’a bloquée, et c’est elle qui montre pourquoi la première a pu la manquer. Sa fenêtre se terminait à l’instant où elle interrogeait Cloudflare, avant l’arrivée des événements les plus récents : son journal enregistre 1 914 requêtes depuis l’adresse, là où mon rejeu de la même fenêtre de cinq minutes le 3 novembre, sur des données complètes, en compte 2 143 ; le rejeu est sauvegardé avec ses commandes et sa sortie complète, et ses chiffres sont des lignes de l’addendum du registre.1943 Le rejeu lève aussi un constat d’inondation du formulaire que l’exécution en direct n’a jamais levé, à partir de 39 envois dont 23 refusés, de gravité élevée, qui aurait été une ligne du récapitulatif quotidien, et non un e-mail ni un blocage.43 J’ai rejoué la fenêtre de la seconde exécution, pas celle de la première : pour l’exécution de 10:57 PST, le même retard est donc la cause probable, pas une cause mesurée. Le jour même, j’ai modifié les fenêtres du moniteur pour qu’elles se terminent 90 secondes avant chaque exécution et commencent là où la précédente s’était arrêtée.43 Six constats au total se sont déclenchés sur l’adresse, et mon moniteur les a tous intégrés à son récapitulatif quotidien au lieu de m’envoyer un e-mail, car l’adresse figurait déjà dans son registre de blocage.19 La façon dont mon moniteur devrait alerter, et ce scanner vu de son côté, font l’objet d’un article distinct ; pour celui-ci, ce qui compte, c’est qu’un blocage est une réaction, et que la réaction est arrivée après au moins 26 des 31 envois.
Le formulaire, par statut
L’adresse a soumis le formulaire 500 fois.2
| UTC | PST | Envoyés (200) | Refus du limiteur (429) | Bloqués à l’edge (403) |
|---|---|---|---|---|
| 18:58 | 10:58 | 3 | ||
| 19:00 | 11:00 | 4 | ||
| 19:02 | 11:02 | 9 | 23 | |
| 19:03 | 11:03 | 10 | 8 | |
| 19:04 | 11:04 | 5 | 22 | |
| 19:05 | 11:05 | 39 | ||
| 19:06 | 11:06 | 19 | ||
| 19:07 | 11:07 | 10 | ||
| 19:08 | 11:08 | 13 | ||
| 19:09 | 11:09 | 31 | ||
| 19:10 | 11:10 | 41 | ||
| 19:11 | 11:11 | 47 | ||
| 19:12 | 11:12 | 97 | ||
| 19:13 | 11:13 | 29 | ||
| 19:14 | 11:14 | 23 | ||
| 19:15 | 11:15 | 21 | ||
| 19:16 | 11:16 | 28 | ||
| 19:17 | 11:17 | 18 |
Les totaux : 31 envoyés, 31 refusés par le limiteur de l’application et 438 refusés à l’edge, tous pendant ou après la minute du blocage. Ainsi, 62 envois ont atteint l’application.2 Chaque envoi d’e-mail prouve que le scanner détenait un jeton CSRF valide, car le gestionnaire vérifie le jeton avant d’envoyer. Les 429 ne prouvent rien dans un sens ou dans l’autre, car le limiteur refuse un envoi avant que le gestionnaire ne lise son jeton.2416 Là où Cloudflare a enregistré le statut de l’origine pour ces 429, c’était celui de l’origine, celui du limiteur applicatif, et il n’existe aucun événement de pare-feu de limitation de débit pour l’adresse.374
Le reste du scan
Par résultat, le scan du 2 novembre se répartit ainsi :14
| Résultat | Requêtes | Part |
|---|---|---|
| Servies (2xx, 3xx) | 1 388 | 12,7 % |
| Refusées par l’application (4xx), dont 420 par la barrière à agents | 1 473 | 13,5 % |
| Erreurs d’origine (520) | 116 | 1,1 % |
| Bloquées à l’edge (403) | 7 930 | 72,7 % |
Les 72,7 % de l’edge sont surtout le blocage : 2 986 requêtes sont arrivées avant la minute du blocage et 7 445 après, et le site n’a servi aucune de ces dernières.14 La requête par chemin était complète ce jour-là : 2 173 chemins distincts, et 778 requêtes sur 542 d’entre eux portaient une charge utile d’injection SQL.44 La requête par user agent était complète elle aussi, 73 chaînes distinctes. Outre la chaîne Chrome ordinaire et la prétention Googlebot, le scanner a envoyé 54 charges utiles SQL distinctes dans 149 requêtes, trois chaînes de rupture de guillemets dans 78, et 12 jetons marqueurs, chacun envoyé une fois.41
Certaines de ces chaînes méritent qu’on s’y arrête, car elles montrent ce que le scanner demandait. (select 198766*667891 from DUAL) est une expression arithmétique dans un select sans table ; FROM dual est la syntaxe d’Oracle pour ce cas, comme le montre l’aide-mémoire de PortSwigger.33 if(now()=sysdate(),sleep(15),0) a la même forme IF(condition, SLEEP(n), x) que la temporisation conditionnelle du même aide-mémoire.33 @@qkLpP est un préfixe @@, celui qu’utilise l’aide-mémoire dans SELECT @@version pour Microsoft et MySQL, suivi de cinq caractères, et le scanner en a envoyé une douzaine du même genre.3341 Le guide de test de l’OWASP nomme les deux techniques que servent ces chaînes : l’injection fondée sur les erreurs, qui « forces the database to generate an error » (force la base de données à produire une erreur), et les temporisations qui « use database commands (e.g. sleep) to delay answers in conditional queries. » (utilisent des commandes de la base, par exemple sleep, pour retarder les réponses dans des requêtes conditionnelles.)45 Aucune des pages que j’ai consultées ne documente ces chaînes exactes : je les décris donc sans les attribuer à un outil particulier.
2 novembre : 10 907 requêtes depuis une seule adresse, par minute, avec les deux exécutions de mon moniteur et le blocage qu’il a posé à 11:04:01 PST.1419
4. Les cinq couches, et ce que chacune pouvait distinguer
Cinq types de contrôle se tenaient entre ces bots et ma boîte de réception. À chacun, j’ai posé la même question : pouvait-il distinguer un bot d’une personne ? Un managed challenge dans les règles d’edge peut poser cette question à un visiteur, mais seulement aux requêtes dont la chaîne user-agent figure sur sa liste, et celles des scanners n’y figuraient pas ; un second, conditionné à un threat score, ne pouvait jamais se déclencher, si bien que rien ne la posait à chaque soumission. Deux des contrôles comptent, un compare des noms et des listes, un prouve qu’un client détient un jeton émis par le site, et un n’attrape que les bots qui remplissent un champ caché. Voici chacun d’eux, ce qu’il vérifie, ce qu’il a arrêté, et la raison exacte pour laquelle il a laissé passer le reste.
Les règles personnalisées de l’edge
Ce qu’elles vérifient. Sur ce site, la première règle personnalisée bloque toute adresse figurant sur une liste au niveau du compte, ainsi que sept réseaux d’hébergement par ASN. Une autre bloque des sous-chaînes de chemins d’exploit comme .php, /wp- et /.env. Deux autres servent un managed challenge sur le formulaire de contact et le chemin d’administration : l’une pour un user agent vide ou contenant curl, wget, python-requests, httpx, scrapy, HeadlessChrome, PhantomJS ou Selenium, l’autre pour un threat score Cloudflare élevé. Le site du studio a les mêmes types de règles, avec les deux challenges appliqués à tout le site et un blocage sur les chemins d’API qui exempte le formulaire de contact.9 Les challenges fondés sur le threat score, un sur chaque site, étaient des règles mortes : la référence des champs de Cloudflare indique que le score allait autrefois de 0 à 100, et « Now, the threat score is always 0 (zero). » (Désormais, le threat score vaut toujours 0.) Une règle qui exige un score élevé ne correspond à rien, et je les ai désactivées toutes les deux le 3 novembre, le jour où j’ai lu cela ; les challenges sur le user agent restent.1146 L’offre Free de Cloudflare autorise cinq règles personnalisées, qui « work as customized web application firewall (WAF) rules that you can use to perform actions like Block or Managed Challenge on incoming requests. » (fonctionnent comme des règles de pare-feu applicatif (WAF) personnalisées, permettant d’appliquer aux requêtes entrantes des actions comme Block ou Managed Challenge.)47
Ce qu’elles ont arrêté. Les chemins d’exploit, dès la première minute les deux jours.104 Le 30 octobre, aucun envoi de formulaire.110 Le 2 novembre, les 438 envois de formulaire postérieurs au blocage de mon moniteur.2 Sur le site du studio, aucun envoi de formulaire : le pare-feu n’a journalisé aucun événement sur un POST vers le formulaire en huit jours.7
Pourquoi le reste est passé. Aucune règle ne regarde ce qu’est une requête. La règle de liste ne correspond à une adresse qu’une fois que quelque chose l’a mise sur la liste, une personne le 30 octobre et mon moniteur le 2 novembre.91019 Le challenge sur le user agent compare une chaîne, et les deux scanners envoyaient une chaîne de navigateur, au moins 69 322 fois le premier jour et 9 725 fois le second.3241 Le challenge sur le user agent fonctionne bel et bien contre les outils qui s’annoncent : mon propre test curl du 30 octobre a déclenché 24 managed challenges.39 Le challenge sur le threat score ne pouvait se déclencher pour personne, et le pare-feu n’a journalisé aucun challenge sur le formulaire pour l’une ou l’autre adresse.11104 Les nœuds de sortie Tor et les réseaux d’hébergement en dehors des sept ASN nommés ne figurent sur aucune liste.
Une règle d’edge peut distinguer une adresse connue, un réseau connu ou un outil honnête du reste, et son challenge ne demande si une personne est là qu’aux requêtes que son expression sélectionne ; elle ne peut pas distinguer d’un navigateur une chaîne de navigateur envoyée par un scanner.
La limite de débit de l’edge
Ce qu’elle vérifie. Le texte de la règle est court :9
(http.request.method eq "POST" and http.request.uri.path in {"/api/contact" "/paint/share"})
Elle compte les requêtes correspondantes par centre de données Cloudflare et par adresse cliente, en autorise 3 par 10 secondes, et bloque pendant 10 secondes. Réparti uniformément, ce plafond équivaut nominalement à 18 par minute par adresse et par centre de données ; la règle elle-même compte par périodes de 10 secondes.916 L’offre Free fixe la période, le délai et la clé de comptage, et accorde à la zone une seule règle de ce type : une période de comptage de 10 secondes, un délai d’atténuation de 10 secondes, et un comptage par IP.48 Le centre de données n’est pas facultatif non plus. « Every rate limiting rule includes the Cloudflare data center ID (cf.colo.id) as a mandatory characteristic » (Toute règle de limitation de débit inclut l’identifiant du centre de données Cloudflare (cf.colo.id) comme caractéristique obligatoire), indique la documentation de Cloudflare, et « Counters are not shared across data centers, with the exception of data centers associated with the same geographical location. » (Les compteurs ne sont pas partagés entre centres de données, sauf entre centres associés au même emplacement géographique.)6
Ce qu’elle a arrêté. Aucune requête des scanners, ni l’un ni l’autre jour. Ses seuls événements dans la fenêtre de l’incident ont été les trois de mon propre test.10439 Le site du studio a sa propre règle de limitation de débit à l’échelle du site, comptée de la même façon sur toutes les requêtes sauf celles des clients que Cloudflare répertorie comme bots vérifiés, et elle n’a refusé aucun des envois de formulaire du goutte-à-goutte : son pare-feu n’a journalisé aucun événement sur un envoi vers le formulaire en huit jours. Cette règle compte toutes les requêtes de ce genre qu’une adresse envoie au site, et mes données ne comptent que les envois de formulaire : elles ne mesurent donc pas la marge qu’il restait au compteur.97
Pourquoi le reste est passé. Le 30 octobre, la règle n’a existé qu’à partir de 18:49:53 UTC, à la même seconde que le premier événement du blocage manuel et environ 12 minutes après que le dernier envoi de formulaire a atteint l’application.91 Les 1 065 envois de ce scanner sont tous passés par un seul centre de données, Paris : une règle en place aurait donc tenu un seul compteur pour eux.5 Le 2 novembre, la règle existait, et les envois de formulaire du scanner sont passés par deux centres de données, Amsterdam avec 44 et Londres avec 41, de 18:56 à 19:05:00 UTC. Dans la minute la plus chargée, 19:02, Londres en a porté 18 et Amsterdam 14.5 Dix-huit en une minute équivaut nominalement au plafond de la règle, mais mes données contiennent des totaux par minute, pas des heures d’arrivée : elles ne peuvent donc pas montrer si une période de 10 secondes dans un centre de données en a contenu plus de trois. Cloudflare est franc sur la précision : « Rate limiting rules are not designed to allow a precise number of requests to reach your origin server. There may be a delay of up to a few seconds between detecting a request and updating rate counters. » (Les règles de limitation de débit ne sont pas conçues pour laisser atteindre votre serveur d’origine un nombre précis de requêtes. Il peut s’écouler jusqu’à quelques secondes entre la détection d’une requête et la mise à jour des compteurs.)48 Sur les sept minutes allant de son premier envoi à atteindre l’application jusqu’au dernier, le scanner a fait en moyenne 8,86 envois par minute.2 Mes notes du jour parlaient d’« about nine a minute » (environ neuf par minute) et s’arrêtaient là ; le partage entre centres de données est la partie qui leur a échappé.23
Je voulais la preuve que la règle fonctionne tout court, alors le matin du 3 novembre je l’ai sondée depuis ma propre machine. À 06:36:49 PST, j’ai envoyé dix POST espacés de 0,4 seconde sans jeton CSRF : quatre sont revenus en 422, parce qu’un champ obligatoire manquait et que FastAPI les a refusés, puis six en 429. À 06:38:03 PST, j’en ai envoyé huit avec tous les champs : trois sont revenus en 403, le refus CSRF de l’origine, puis cinq en 429 avec le corps « error code: 1015 » de Cloudflare. Le pare-feu a journalisé 11 événements sur le formulaire pendant ces minutes, tous des blocages de la règle de limitation de débit.18 La règle se déclenche, et Cloudflare le journalise. Ma lecture du 2 novembre est une hypothèse : réparti sur deux compteurs et tournant près du plafond nominal sur chacun, le scanner n’a déclenché ni l’un ni l’autre. C’est l’explication la plus solide compatible avec les totaux par minute, pas une démonstration ; les totaux ne peuvent pas montrer comment les envois étaient espacés à l’intérieur de chaque minute, et Cloudflare admet que des requêtes excédentaires peuvent passer pendant la mise à jour de ses compteurs.548
Une limite de débit peut distinguer le rapide du lent, pour une adresse dans un centre de données ; elle ne peut pas distinguer qui envoie.
Le jeton CSRF
Ce qu’il vérifie. Le formulaire de ce site porte un jeton caché, rendu dans la page, en trois parties : un horodatage, une valeur aléatoire et une signature HMAC sur les deux premières. Le serveur l’accepte si la signature correspond et si l’horodatage date de moins d’une heure.15 Le formulaire du site du studio n’a pas de jeton CSRF.25
Ce qu’il a arrêté. Quatre envois le 30 octobre, tous dans la minute de 18:03, et aucun le 2 novembre.12
Pourquoi le reste est passé. Le jeton est sans état. Il n’est lié ni à une session, ni à un cookie, ni à une adresse : tout client qui récupère la page en obtient un valide, et tout client à qui un autre remet un jeton peut le présenter tant qu’il est valable. Les deux scanners ont bien récupéré les pages du site ; c’est ce que montrent les journaux de requêtes. Ce que montre la vérification est plus étroit : chaque envoi des deux jours portait un jeton valide et non expiré émis par mon site, car le gestionnaire vérifie le jeton avant d’envoyer.1513142416 Un jeton CSRF existe pour empêcher un autre site de faire soumettre mon formulaire par le navigateur d’un visiteur. Il n’a jamais été conçu pour arrêter un client qui veut soumettre le formulaire lui-même, et il ne l’a pas fait.
La même vérification avait un second défaut, et il retombait sur des personnes plutôt que sur des bots. Quand le jeton échouait, le gestionnaire levait un 403 JSON que le formulaire HTMX n’affichait jamais : une personne munie d’un jeton expiré aurait cliqué sur Envoyer sans rien voir du tout.12 J’ai corrigé ce défaut, même si ma première correction n’a pas fonctionné non plus ; la section 6 explique comment.
Un jeton CSRF valide prouve que le client détenait un jeton non expiré émis par mon site ; il ne peut pas prouver qui a rempli le formulaire, ni même que ce client a chargé la page.
Le limiteur de débit de l’application
Ce qu’il vérifie. Le gestionnaire de ce site se trouve derrière un décorateur qui compte les envois de formulaire par adresse cliente et par minute d’horloge dans Cloudflare KV, avec un repli en mémoire, et refuse au-delà de cinq avec un 429.17 Le site du studio a, pour les clients anonymes, un limiteur en mémoire propre à chaque processus, compté par adresse et par classification du client, la catégorie que le limiteur attribue d’après le user agent et l’adresse, sur tous les chemins sauf les contrôles de santé et les fichiers statiques.25
Ce qu’il a arrêté. 830 envois le 30 octobre et 31 le 2 novembre. Aucun sur le site du studio.127
Pourquoi le reste est passé. Par construction, il laisse passer cinq envois par minute à n’importe qui, et comme la fenêtre est la minute d’horloge, une rafale à cheval sur une frontière de minute obtient deux quotas.17 Cela, je le savais. Ce que j’ignorais jusqu’à ce que je confronte les chiffres au code, c’est qu’il fuit, et que le code a au moins trois façons de fuir. Le limiteur lit le compteur dans KV via HTTP, le compare à la limite, puis écrit le compte plus un dans une requête distincte : deux envois qui lisent tous deux avant que l’un ou l’autre n’écrive passent donc tous les deux. L’incrément relit le compteur avant d’écrire, et si cette seconde lecture échoue, l’écriture remet le compte à un, et la minute repart de zéro. Et chaque fois que la lecture KV ne renvoie rien ou que l’écriture échoue, la décision se rabat sur une liste gardée dans la mémoire d’un seul processus, que les autres processus ne partagent pas.17
Les chiffres montrent la fuite. Le 30 octobre, huit minutes ont compté plus de cinq envois, et la minute de 18:06 en a compté 18.1 Un limiteur exact à cinq par minute aurait autorisé au plus 54 envois sur les minutes où le scanner a posté, et 101 sont partis, 47 au-delà du plafond. Le 2 novembre, les minutes de 19:02 et 19:03 en ont laissé passer 9 et 10 ; un limiteur exact aurait autorisé au plus 22 des 62 envois qu’il a vus, et 31 sont partis, 9 au-delà du plafond.3 Ils ne montrent pas quel chemin a fui. Ma lecture est que des envois ont lu le même compte avant que l’un d’eux n’écrive, ou que des appels KV ont échoué sous la charge, remettant le compte à zéro ou confiant la décision aux listes propres à chaque processus, ou un mélange de ces cas ; les journaux dont je dispose enregistrent le statut renvoyé par l’application, pas le chemin qui a décidé, et ne peuvent donc pas distinguer ces chemins.173 Le remède est le même pour tous, et ce n’est pas une modification d’une ligne : un stockage de compteur avec incrément atomique, ce que ne fournit pas une lecture KV suivie d’une écriture distincte. La fuite est mon bug. Elle rend aussi l’argument de cet article plus tranchant que je n’aurais pu le faire exprès : un contrôle qui dit cinq et laisse passer dix-huit n’allait jamais être ce qui décide qui a le droit de m’écrire.
Sur le site du studio, le limiteur de l’application n’a refusé aucun des envois de formulaire observés, et l’adresse la plus active en a envoyé 13 en une heure. Ses compteurs incluent les requêtes d’une adresse vers tous les autres chemins sous la même classification de client, et mes données ne couvrent que les envois de formulaire : les chiffres ne mesurent donc pas la marge qu’avaient ces compteurs.725
Un limiteur de débit peut distinguer cinq de six quand les requêtes arrivent chacune à leur tour ; il ne peut distinguer une personne d’un script à aucun volume.
Le honeypot
Ce qu’il vérifie. Le formulaire du site du studio a un champ de saisie texte nommé website qu’une personne ne voit jamais : il porte tabindex="-1" et aria-hidden, si bien que ni le clavier ni un lecteur d’écran ne s’y arrêtent. S’il revient avec un contenu quelconque, le gestionnaire répond avec le même message de succès qu’une personne verrait, et n’enregistre ni n’envoie rien.25 Cette branche figure dans le gestionnaire depuis que j’ai construit le formulaire, le 5 juin.25 Le formulaire de ce site n’a pas de honeypot.24
Ned Batchelder a décrit la technique en 2007 : « These fields are called honeypots and are validated when the form data is posted. If they contain any text, then the submitter must be a bot » (Ces champs s’appellent des honeypots et sont validés à l’envoi des données du formulaire. S’ils contiennent du texte, l’expéditeur est forcément un bot).49 La raison pour laquelle cela fonctionne, selon lui, est que les bots qui remplissent les formulaires « cannot distinguish invisible fields from visible ones. » (ne savent pas distinguer les champs invisibles des champs visibles.)49
Ce qu’il a arrêté. 42 des 52 envois en huit jours, soit 80,8 %, dont, d’après l’heure de leur arrivée, chaque envoi venant d’un nœud de sortie Tor et, d’après l’heure et le motif des messages, les 13 d’une rafale d’un SQL spider qui se présentait comme tel.720
Pourquoi le reste est passé. Un bot qui ne remplit que les champs visibles ne touche jamais website, et les expéditeurs dont les envois ont été enregistrés, et pouvaient donc atteindre ma boîte de réception, l’ont laissé vide.825 Qu’ils aient choisi les champs visibles à dessein relève de ma lecture ; le gestionnaire n’enregistre que le fait que le champ caché est revenu vide. Batchelder l’avait vu venir : « In theory, it is possible for a spambot to defeat any of these measures. But in practice, bots are very stupid » (En théorie, un spambot peut déjouer n’importe laquelle de ces mesures. Mais en pratique, les bots sont très bêtes).49 Certains sont moins bêtes que d’autres, et ceux qui vendent de la génération de leads sont les moins bêtes.
Un honeypot prouve seulement que le client a rempli un champ qu’une personne ne peut pas voir ; un honeypot vide ne prouve rien.
Jusqu’où chaque flux est allé : les envois des deux scanners sur ce site, et huit jours de goutte-à-goutte sur le site du studio.1278
Ce que les cinq avaient en commun
Lisez les cinq phrases ensemble et la faille saute aux yeux. Les limites de débit bornent le volume par adresse. Un jeton CSRF prouve qu’un client détenait un jeton émis par le site. Un honeypot prouve que le client a rempli un champ caché. Les règles d’edge connaissent des noms et des listes, et celle qui pouvait demander si une personne se trouve à l’autre bout, le challenge sur le user agent, ne le demande qu’aux requêtes dont la chaîne user-agent figure sur sa liste ; le challenge fondé sur le threat score, à côté, ne pouvait jamais se déclencher. Aucun des deux scanners ne correspondait, si bien que rien ne posait la question à chaque soumission, et les scanners n’ont rien fait qu’un navigateur ne puisse faire : ils ont récupéré la page, gardé son jeton et posté à un rythme que les limites laissaient partiellement passer. Les recommandations de l’OWASP sur le credential stuffing font la même remarque à propos d’un autre formulaire, en conseillant des défenses qui « not rely on a single predictable volume limit » (ne reposent pas sur une seule limite de volume prévisible).50
5. Le lent goutte-à-goutte sur le site du studio
Le formulaire du site du studio n’a jamais connu de vague. Il a connu un goutte-à-goutte, et le goutte-à-goutte est une tout autre bête. La fenêtre va ici de la première heure comportant un envoi de formulaire dans la requête sur huit jours, 17:00 UTC le 26 octobre, jusqu’au moment où j’ai sauvegardé les données, 13:59:50 UTC le 3 novembre ; le premier et le dernier jour sont partiels.7
Les envois face aux lignes enregistrées
Le formulaire a reçu 52 POST dans cette fenêtre et a répondu 200 à chacun. La table des messages a gagné 10 lignes sur la même période. Comme la branche du honeypot est le seul chemin du gestionnaire qui répond 200 sans rien enregistrer, le honeypot a intercepté les 42 autres, soit 80,8 %.7825 Par jour UTC :78
| Date (UTC) | Envois (200) | Lignes enregistrées | Interceptés par le honeypot |
|---|---|---|---|
| 26 oct. (partiel) | 4 | 2 | 2 |
| 27 oct. | 5 | 0 | 5 |
| 28 oct. | 3 | 2 | 1 |
| 29 oct. | 5 | 1 | 4 |
| 30 oct. | 16 | 1 | 15 |
| 31 oct. | 8 | 2 | 6 |
| 1er nov. | 4 | 0 | 4 |
| 2 nov. | 5 | 0 | 5 |
| 3 nov. (partiel) | 2 | 2 | 0 |
Le pic du 30 octobre, c’est une adresse au Royaume-Uni qui a envoyé 13 soumissions en une seule heure avec le user agent Mozilla/5.0 (compatible; SQLSpider/1.5). D’après l’heure et le motif des messages, la table n’en a enregistré aucune : la seule ligne enregistrée dans cette heure-là est un argumentaire de génération de leads, le motif de l’adresse américaine qui a elle aussi posté dans cette heure.7820
Qui envoyait
Cloudflare marque les requêtes venant du réseau Tor d’un code pays spécial, T1. Sa référence des en-têtes indique « T1 - Used for clients using the Tor network » (T1 : utilisé pour les clients passant par le réseau Tor), et sa page sur les IP Access rules indique « T1: Tor exit nodes (country name: Tor) » (T1 : nœuds de sortie Tor, nom de pays : Tor).5152 D’après ce code, 23 des 52 envois venaient de Tor, soit 44,2 %, et 23 des 35 adresses distinctes étaient des nœuds de sortie Tor, soit 65,7 %.7 Par recoupement, j’ai comparé chaque adresse ayant soumis le formulaire à la liste globale des sorties du Tor Project, que le projet qualifie de « a nearly real time authoritative source for Tor exits that allow contacting your server on port 80 » (source de référence quasi en temps réel des sorties Tor qui permettent de contacter votre serveur sur le port 80). Les 23 adresses que Cloudflare avait codées T1 figuraient toutes sur la liste que j’ai récupérée le 3 novembre, et aucune des 12 autres.5354 Les deux sources concordent. Cette liste est toutefois celle des sorties du 3 novembre : elle confirme le code pour ce jour-là et ne prouve pas quelles adresses étaient des sorties le jour d’arrivée de chaque envoi. L’archive CollecTor du projet conserve les listes de sorties passées et permettrait de trancher l’appartenance le jour des faits ; je ne l’ai pas interrogée.55
La plupart des adresses de cette fenêtre n’ont posté qu’une fois : 32 sur 35, soit 91,4 %, et toutes les sorties Tor parmi elles.7 Les trois qui ont posté plus d’une fois étaient le SQL spider avec 13, une adresse en Pologne avec deux en une heure, et une adresse aux États-Unis avec cinq. Les cinq envois de cette dernière tombent dans les cinq mêmes heures que les cinq argumentaires de génération de leads que la table a enregistrés dans la fenêtre, et elle changeait de user agent à chaque fois : Chrome 135 sous Linux, puis Firefox 128, 137, 138, et de nouveau 137.720
Les user agents se répartissent selon la même ligne. Parmi les envois Tor, 18 désignaient un appareil Android et 5 un Chrome de bureau, et 21 des 23 désignaient Chrome 81 ou plus ancien. Les envois hors Tor désignaient des navigateurs de bureau bien plus récents, 10 Chrome de bureau de 125 à 135 et 6 Firefox de 128 à 138, plus les 13 du spider sous son propre nom.7
Qui a été enregistré
Les deux moitiés se sont rejointes au honeypot. Aucune ligne enregistrée dans la fenêtre ne se situe dans une heure qui n’aurait compté que des envois Tor, et les 10 se situent toutes dans une heure comportant un envoi hors Tor. 22 des 23 envois Tor sont tombés dans des heures sans aucune ligne enregistrée, et le 23e partage son heure avec l’adresse américaine de génération de leads.20 J’ai fait la correspondance à l’heure, car les journaux de requêtes et la table des messages n’ont aucun identifiant de requête en commun : la correspondance est donc une inférence. D’après l’heure de leur arrivée, chaque soumission Tor de la fenêtre a déclenché le honeypot.20 Ce qui a été enregistré, et pouvait donc atteindre la boîte de réception, venait d’adresses hors Tor, qui envoyaient des chaînes de navigateurs de bureau plus récents et laissaient le honeypot vide.
Les chiffres correspondent au vieux schéma du spam de formulaire. La taxonomie de Batchelder, en 2007, répartit les producteurs de spam entre « Playback spambots, form-filling spambots, and humans. » (spambots de rejeu, spambots remplisseurs de formulaires et humains.)49 Le goutte-à-goutte Tor se comporte comme des remplisseurs de formulaires qui remplissent tous les champs et tombent dans le honeypot. Les expéditeurs qui sont passés se comportent davantage comme une personne au clavier, ou comme un script conçu pour lui ressembler. L’OWASP range cette activité sous OAT-017, Spamming : « Malicious or questionable information addition to public or private content, or messages. » (Ajout d’informations malveillantes ou douteuses à des contenus ou messages publics ou privés.)56 L’OWASP exclut aussi un cas : « The mass abuse of broken form-to-email and form-to-SMS functions, to send messages to unintended recipients, is not included in this threat event » (L’abus massif de fonctions défaillantes de formulaire vers e-mail ou SMS, pour envoyer des messages à des destinataires non prévus, n’est pas inclus dans cet événement de menace).56 Mes formulaires n’écrivent qu’à moi : ils relèvent donc du cas inclus, le spam adressé au propriétaire du site, et non de celui-là.
Le goutte-à-goutte met aussi en échec, par construction, la limitation de débit. Les recommandations de l’OWASP sur le credential stuffing nomment l’effet de la répartition des requêtes sur de nombreuses adresses : « This may defeat both IP block-lists and rate limiting, as per IP request volume may remain relatively low » (Cela peut déjouer à la fois les listes de blocage d’IP et la limitation de débit, car le volume de requêtes par IP peut rester relativement faible).50 Un envoi par adresse, c’est le volume le plus bas qui soit.
Ce que disent les messages enregistrés
La table des messages remonte au 6 juin et comptait 108 lignes quand je l’ai lue ; la plus récente est arrivée à 07:24 UTC le 3 novembre.8 Je les ai triées par motif selon des règles fixes, la première correspondance l’emportant :57
| Motif | Table entière | Octobre |
|---|---|---|
| « Hi, I wanted to know your price » (Bonjour, je voulais connaître votre prix), une langue par ligne, un seul nom d’expéditeur | 49 | 10 |
| Argumentaires de prestataires : leads web, croissance Instagram, vidéo explicative, SEO, données B2B, un annuaire, design | 22 | 4 |
| Appâts à gains : jackpot, code promo, PlayStation, Lamborghini, liens Rolls-Royce, un seul nom d’expéditeur | 16 | 3 |
| Un lien « $50,000,000 per month » (50 millions de dollars par mois) sur telegra.ph | 9 | 9 |
| Un argumentaire de génération de leads : « If I were a real customer, how soon would I get a call back? » (Si j’étais un vrai client, en combien de temps serais-je rappelé ?) | 6 | 5 |
| Autres : deux chaînes de charabia, une demande de liste de diffusion, un mot me demandant d’écrire ailleurs | 4 | 1 |
| Mon propre test de la notification par e-mail, le 21 septembre | 1 | 0 |
| Une vraie demande, selon ma lecture | 1 | 0 |
Quelques-unes méritent qu’on s’y attarde. Les neuf lignes à $50,000,000 sont arrivées sous un même nom d’expéditeur avec neuf adresses d’expéditeur différentes, chacune une petite variation du même titre.8 La question du prix apparaît en 25 langues sur l’ensemble de la table : afrikaans, arménien, basque, biélorusse, bengali, bulgare, catalan, croate, danois, anglais, géorgien, grec, hongrois, islandais, indonésien, irlandais, italien, latin, letton, lituanien, luxembourgeois, espagnol, vietnamien, gallois et zoulou. Rien qu’en octobre, elle est arrivée en neuf : arménien, basque, danois, indonésien, latin, letton, lituanien, espagnol et vietnamien.57 Deux des attributions de langue sont de moi et se jouent à peu : la phrase que je lis comme du croate pourrait être du bosnien, et celle que je lis comme de l’indonésien pourrait être du malais.57 Quelqu’un fait défiler un guide de conversation dans un formulaire de contact, une langue à la fois.
Le rythme est l’autre signature du goutte-à-goutte. Octobre a enregistré 32 lignes, 1,03 par jour, sur 21 de ses 31 jours, et jamais plus de trois en un jour.8 Aucune limite de débit par adresse ne se déclenche à un envoi par jour.
Lesquels ont atteint la boîte de réception
Le formulaire n’envoie d’e-mails à la boîte du studio que depuis le 21 septembre à 17:46:47 UTC. Depuis, il a enregistré, et donc envoyé, 43 lignes, une borne supérieure, car l’appel de notification se fait au mieux et je n’ai pas vérifié l’heure du déploiement ; les 65 lignes enregistrées avant cette date n’ont jamais été envoyées.258 Chaque ligne de la fenêtre étudiée est du spam.57
En dehors de mon propre test, une seule ligne de toute la table n’en est pas. Le 14 septembre, un client de l’un des produits du studio a écrit au sujet d’une erreur précise, avec la voix d’une personne, et le message est resté dans la table sans que personne ne le lise.57 Quand j’ai branché le formulaire sur l’envoi d’e-mails à la boîte une semaine plus tard, le message de commit disait pourquoi en toutes lettres : « a paying customer’s message sat unread in D1 for a week » (le message d’un client payant est resté non lu dans D1 pendant une semaine).25 Ce message est la raison d’être de la notification, et c’est la raison pour laquelle je ne peux pas régler le spam en coupant la notification. Je garde ici l’expéditeur anonyme ; ce qui compte, c’est qu’une vraie personne se trouve dans la même table que le guide de conversation.
6. Ce qu’est une vérification anti-bot, et ce qui a été mis en ligne
Turnstile, selon les mots de Cloudflare
Cloudflare présente Turnstile comme son « smart CAPTCHA alternative » (alternative intelligente au CAPTCHA), et affirme qu’il « can be embedded into any website without sending traffic through Cloudflare and works without showing visitors a CAPTCHA. » (peut être intégré à n’importe quel site sans faire passer le trafic par Cloudflare, et fonctionne sans montrer de CAPTCHA aux visiteurs.)21 Le mécanisme est la partie qui répond à ma question. « First, we run a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment » (D’abord, nous exécutons une série de petits challenges JavaScript non interactifs pour recueillir des signaux sur le visiteur ou l’environnement du navigateur), dit la présentation, et « These challenges include proof-of-work (computational puzzles), proof-of-space, probing for web APIs, and various other challenges for detecting browser-quirks and human behavior. » (Ces challenges comprennent la preuve de travail (des énigmes de calcul), la preuve d’espace, le sondage d’API web, et divers autres challenges destinés à détecter les particularités des navigateurs et le comportement humain.)21 Une limite de débit demande combien. Un jeton CSRF demande si le client détient un jeton émis par le site. Turnstile demande quel genre de client se trouve à l’autre bout. Mon challenge sur le user agent pouvait le demander aussi, mais seulement aux requêtes dont la chaîne user-agent figurait sur sa liste, et mon challenge sur le threat score ne pouvait jamais se déclencher ; la différence qui compte ici oppose un challenge déclenché de façon sélective à une vérification exigée de chaque soumission.
Le widget a trois modes. J’utilise le mode managed, que Cloudflare décrit comme un mode qui « Automatically chooses between non-interactive or checkbox challenge based on visitor risk level. No images or text to decipher. » (choisit automatiquement entre un challenge non interactif et une case à cocher selon le niveau de risque du visiteur. Aucune image ni aucun texte à déchiffrer.)58 Concrètement, un visiteur voit une brève vérification et rien d’autre, sauf si les signaux en demandent davantage, auquel cas il obtient une case à cocher. Le mode invisible masque même cela, et Cloudflare l’assortit d’une condition : un site qui l’active doit mentionner le Turnstile Privacy Addendum de Cloudflare dans sa propre politique de confidentialité.58 Turnstile a sa propre offre gratuite, jusqu’à 20 widgets, avec des challenges illimités, et Cloudflare indique qu’il « can be used independently without requiring other Cloudflare services. » (peut être utilisé de façon autonome, sans nécessiter d’autres services Cloudflare.)59
La moitié serveur, celle qui compte
Le widget seul ne fait rien pour un formulaire, et Cloudflare le dit dès les premières lignes de son guide côté serveur : « You must call the Siteverify API to complete your Turnstile implementation. The client-side widget alone does not protect your forms. » (Vous devez appeler l’API Siteverify pour compléter votre intégration de Turnstile. Le widget côté client ne protège pas à lui seul vos formulaires.) Puis, plus brutalement : « Tokens can be forged. An attacker can submit any string to your form endpoint without completing a challenge. » (Les jetons peuvent être falsifiés. Un attaquant peut soumettre n’importe quelle chaîne à votre point de terminaison de formulaire sans résoudre de challenge.)60 Le serveur envoie le jeton à https://challenges.cloudflare.com/turnstile/v0/siteverify avec sa clé secrète, et peut y joindre l’adresse IP du visiteur sous la forme remoteip.60
Deux propriétés du jeton façonnent le code qui l’entoure. « Each token is valid for 300 seconds (5 minutes) after generation. » (Chaque jeton est valable 300 secondes (5 minutes) après sa génération.) Et « Tokens are single-use. Each token can only be validated once. A replayed token will be rejected with the timeout-or-duplicate error code. » (Les jetons sont à usage unique. Chaque jeton ne peut être validé qu’une fois. Un jeton rejoué sera rejeté avec le code d’erreur timeout-or-duplicate.)60 Un scanner qui aurait récolté un jeton ne pourrait pas s’en servir pour soumettre le formulaire 1 065 fois. Cloudflare limite aussi le jeton à 2 048 caractères et demande au serveur de « Validate the action and hostname when specified. » (valider l’action et le nom d’hôte lorsqu’ils sont précisés.)60 Une réserve sur la même page : elle cite timeout-or-duplicate pour un jeton expiré à un endroit, et décrit invalid-input-response comme « Token is invalid, malformed, or expired » (jeton invalide, mal formé ou expiré) dans son tableau d’erreurs ; un serveur devrait donc traiter tout échec comme un refus plutôt que de faire dépendre sa logique d’un seul code.60
Ce qu’il traite, selon les mots de Cloudflare
Cloudflare ne prétend pas que Turnstile n’utilise aucune donnée personnelle, et je ne le prétendrai pas non plus. Son addendum de confidentialité pour Turnstile nomme les signaux : « Cloudflare Turnstile processes a variety of client-side signals (“Signals”) such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin. » (Cloudflare Turnstile traite divers signaux côté client, comme l’adresse IP du client, l’empreinte TLS, l’en-tête User-Agent, ainsi que la sitekey et l’origine associée.) Il en énonce la finalité : « The purpose of collecting these Signals is not to identify, profile or target any individuals but solely to detect and block bots. » (Ces signaux ne sont pas collectés pour identifier, profiler ou cibler qui que ce soit, mais uniquement pour détecter et bloquer les bots.) Et il précise que Cloudflare s’en sert aussi pour améliorer le produit : « Cloudflare is a data controller of Signals that we process to improve Turnstile’s bot detection capabilities. » (Cloudflare est responsable du traitement des signaux que nous traitons pour améliorer la détection des bots de Turnstile.)22 Ce que Cloudflare dit laisser de côté, c’est le contenu : « Turnstile does not access, store, or transmit user communications, form entries, or other page inputs. » (Turnstile n’accède pas aux communications des utilisateurs, aux saisies de formulaire ni aux autres saisies de la page, et ne les stocke ni ne les transmet.)21 La page produit ajoute que « Turnstile never harvests data for ad retargeting. » (Turnstile ne collecte jamais de données pour le reciblage publicitaire.)61 Ma lecture : Turnstile voit le même genre de données de connexion que mon edge journalise déjà pour chaque requête, et ne voit jamais ce qu’une personne tape dans mon formulaire.
La CSP dont il a besoin
Turnstile se charge depuis l’origine des challenges de Cloudflare, et une Content Security Policy doit l’autoriser. Cloudflare indique deux directives : « script-src: https://challenges.cloudflare.com » et « frame-src: https://challenges.cloudflare.com ».62 Mes deux zones définissent leur politique dans une règle Cloudflare d’en-têtes de réponse. Sur ce site, la liste des frames existait déjà et a reçu l’origine des challenges. Le site du studio n’avait aucun frame-src, si bien que son default-src 'self' aurait bloqué l’iframe du widget ; il a reçu frame-src 'self' plus l’origine des challenges, et la politique de l’application, dans le code, a reçu la même chose.12
Pourquoi pas un managed challenge sur le POST du formulaire lui-même
Le raccourci évident se trouvait déjà dans ma zone : une règle personnalisée qui répond à un POST vers le formulaire par un managed challenge. Mes règles existantes le font pour curl et ses semblables, et cela fonctionne, car personne n’utilise curl pour m’envoyer un message.939 L’étendre à tous les POST aurait cassé le formulaire pour tout le monde. Cloudflare documente directement la limite : « Challenge Pages interrupt the request flow by returning a full HTML page for the user’s browser to render and solve. This mechanism fails when the browser expects a non-HTML response, such as an AJAX or XHR (fetch) request. » (Les pages de challenge interrompent le flux de la requête en renvoyant une page HTML complète que le navigateur de l’utilisateur doit afficher et résoudre. Ce mécanisme échoue quand le navigateur attend une réponse non HTML, comme une requête AJAX ou XHR (fetch).)63 Le formulaire de contact de ce site est un formulaire HTMX, qui poste avec hx-post et insère la réponse dans la page : son POST est exactement ce genre de requête ; la documentation ne nomme pas HTMX, et le rapprochement est ma lecture.24 Un POST soumis à un challenge reviendrait sous forme de page de challenge HTML, que Cloudflare signale par un en-tête cf-mitigated « set to challenge » (positionné sur challenge), et « Regardless of the requested resource-type, the content-type of a challenge will be text/html. » (Quel que soit le type de ressource demandé, le content-type d’un challenge sera text/html.)64 Le formulaire insérerait une page de challenge dans sa zone de réponse, ou rien.
La réponse de Cloudflare elle-même pour ce cas est une autre fonctionnalité : « To ensure your API calls are protected without breaking single-page applications (SPAs) or API integrations, Cloudflare recommends using Turnstile Pre-clearance. » (Pour protéger vos appels d’API sans casser les applications monopages (SPA) ni les intégrations d’API, Cloudflare recommande d’utiliser Turnstile Pre-clearance.)63 Avec la pre-clearance, un widget résolu émet en plus un cookie cf_clearance qui « enables visitors to bypass WAF Challenges on all subsequent requests on the zone, including API requests, based on the security clearance level set by the customer. » (permet aux visiteurs de contourner les challenges du WAF sur toutes les requêtes suivantes vers la zone, y compris les requêtes d’API, selon le niveau d’autorisation de sécurité défini par le client.) Le cookie ne remplace pas le jeton : Cloudflare dit qu’il « is issued to the visitor in addition to the default Turnstile token » (est délivré au visiteur en plus du jeton Turnstile par défaut), et la même page exige toujours la vérification par le serveur : « You must call Siteverify to complete your Turnstile configuration. » (Vous devez appeler Siteverify pour compléter votre configuration de Turnstile.)65 Ce qu’ajoute la pre-clearance, c’est un challenge à l’edge auquel un POST XHR peut survivre, pour un visiteur dont le cookie détient une autorisation valide au niveau du challenge ou au-dessus. J’ai retenu le fonctionnement par défaut : un widget dans le formulaire et un jeton vérifié par mon propre serveur.
Ce qui a été mis en ligne le 3 novembre
Sur les deux sites, j’ai créé via l’API de Cloudflare un widget en mode managed, limité au propre domaine du site. Le secret de chaque widget est passé de la réponse de l’API au trousseau de mon Mac et aux variables du service d’hébergement au sein d’un même processus, sans jamais apparaître sur une ligne de commande ni dans un fichier.12
Ce site. Un nouveau vérificateur envoie le jeton à siteverify avec l’adresse du visiteur. Un jeton absent, un jeton de plus de 2 048 caractères, un jeton rejeté ou une erreur de transport renvoient tous false : le formulaire échoue donc en position fermée (fail closed) dès que le secret est défini ; sans secret, comme en développement local et dans la suite de tests, le formulaire fonctionne comme avant.12 Un jeton que Cloudflare accepte doit aussi avoir été servi sur l’un des noms d’hôte propres à ce site, et lorsque Cloudflare renvoie l’action du widget, ce doit être celle du formulaire lui-même, comme le conseille le guide côté serveur ; une réponse sans nom d’hôte est un refus. Un jeton valide créé sur un autre site, ou par un autre widget, ne passe donc pas. Cette vérification est arrivée par une deuxième modification le même matin, construite à partir de 07:20 PST ; à 07:25 PST, après une purge du cache, un envoi portant un jeton CSRF valide tiré de la page en ligne et sans jeton Turnstile est de nouveau revenu en 403. Cela prouve que le formulaire échoue toujours en position fermée après le déploiement ; un envoi sans jeton ne peut pas exercer la vérification du nom d’hôte elle-même, qui repose sur le code et les tests.6660 Jusqu’à une troisième modification, les deux widgets et les deux vérificateurs admettaient aussi localhost, et comme une sitekey est publique, un jeton créé avec la mienne sur le localhost de n’importe qui aurait passé siteverify comme la vérification du nom d’hôte. La page de test de Cloudflare le déconseille : « Cloudflare recommends that sitekeys used in production do not allow local domains » (Cloudflare recommande que les sitekeys utilisées en production n’autorisent pas les domaines locaux), et elle cite localhost parmi eux. À 08:12 PST, j’ai retiré localhost des deux widgets, et les deux vérificateurs n’admettent plus par défaut que le site et son hôte www : la vérification admet les noms d’hôte propres à ce site et rien d’autre ; le développement local déclare ses noms d’hôte dans un réglage. Après cette modification, les deux sites ont de nouveau passé les mêmes vérifications en ligne, à 08:15 et 08:18 heure du Pacifique : le widget sur la page et un envoi sans jeton refusé en 403. Aucune vérification en ligne ne peut exercer la règle du nom d’hôte elle-même, puisqu’un jeton absent ou bidon échoue avant que le nom d’hôte ne soit lu ; cette règle repose sur les réglages du widget, le code et les tests.4667 L’ordre des vérifications du gestionnaire est désormais CSRF, puis Turnstile, puis l’envoi, derrière le même limiteur par adresse qu’avant. Les deux refus renvoient le fragment d’erreur de la page avec un 403, et HTMX n’insère pas par défaut une réponse 4xx dans la page : le formulaire a donc besoin d’un gestionnaire qui laisse passer un 403. Ma première version de ce gestionnaire ne fonctionnait pas. Je l’avais attachée au formulaire, mais HTMX déclenche l’événement sur l’élément qui reçoit la réponse, et sur cette page cet élément est un frère du formulaire : l’événement n’atteignait jamais le gestionnaire, et une personne refusée ne voyait toujours rien. Une deuxième relecture du brouillon de cet article l’a repéré en lisant le HTMX embarqué à la lumière du gabarit. Le correctif, quatrième modification de la matinée, place l’écouteur sur le body de la page, limité à la zone de réponse, et un test vérifie qu’il est bien rendu. À 08:21 PST, après ce déploiement, j’ai fait tourner le formulaire dans le Chromium de Playwright avec le jeton du widget remplacé par une chaîne bidon : la zone de réponse a affiché « error: Verification failed. Reload the page and try again. » (erreur : la vérification a échoué. Rechargez la page et réessayez.), et rien n’a été envoyé, car siteverify refuse un jeton bidon avant que le gestionnaire n’atteigne l’envoi.68 L’ancien refus CSRF, le 403 JSON que HTMX n’affichait jamais, a disparu. Le formulaire réinitialise son propre widget après chaque requête HTMX, en nommant le conteneur du widget comme l’attend l’appel de réinitialisation de Cloudflare, si bien qu’une nouvelle tentative porte un jeton neuf, puisque chaque jeton est à usage unique et vit 300 secondes.126069 Le guide côté client de Cloudflare donne la raison et l’appel : « To reset the widget if the given widget timed out or expired, you can use the function: » (Pour réinitialiser le widget s’il a expiré, vous pouvez utiliser la fonction :) turnstile.reset(widgetId);.69 Ma première version appelait la réinitialisation sans argument, ce que le guide ne documente pas ; une quatrième passe de relecture l’a repéré, l’appel nomme désormais le conteneur sur les deux sites, et une vérification dans le navigateur le 3 novembre a soumis un jeton bidon sur chaque formulaire, constaté le refus, puis trouvé un nouveau jeton dans le champ caché : en moins de huit secondes sur le site du studio, en moins de vingt-cinq sur celui-ci, où le widget a d’abord relancé son challenge.12 Le div du widget et le script ne sont rendus que si la clé de site est définie. Onze tests passent : sans secret, l’envoi se fait comme avant ; avec un secret, le gestionnaire refuse un jeton absent avant tout envoi et refuse un jeton rejeté, un jeton accepté déclenche l’envoi, et CSRF passe toujours en premier ; et le vérificateur accepte le nom d’hôte de ce site et refuse un autre nom d’hôte, localhost par défaut, une autre action, ou aucune.126646
Le site du studio. Le honeypot reste en tête. Un champ caché rempli reçoit toujours le message de succès et rien n’est enregistré, si bien que les bots les plus frustes ne coûtent jamais un appel à Cloudflare. Vient ensuite Turnstile, et un jeton absent, rejeté ou invérifiable reçoit un 403 avec le fragment d’erreur. Puis la validation, l’insertion dans la table des messages et la notification.12 Son vérificateur contrôle de la même façon le nom d’hôte (désormais uniquement les deux noms d’hôte propres au site) et l’action ; cette modification a été déployée à 07:19 PST, et à 07:22 PST la page en ligne portait le widget et la clé de site, un envoi sans jeton et un envoi avec un jeton bidon sont revenus en 403, et un envoi déclenchant le honeypot recevait toujours un 200.66 Avant le déploiement, j’ai exécuté le gestionnaire en local avec les clés de test documentées par Cloudflare. Avec le secret qui réussit toujours, un envoi sans jeton est revenu en 403, et le jeton factice a passé Turnstile et atteint la validation ; avec le secret qui échoue toujours, les deux sont revenus en 403 ; le honeypot a répondu 200.1267 La page de test de Cloudflare explique pourquoi un jeton factice ne peut pas passer en production : « Production secret keys: Only accept real tokens, reject dummy tokens. » (Clés secrètes de production : n’acceptent que les vrais jetons, rejettent les jetons factices.)67
La vérification en ligne. Plus tôt ce matin-là, après la réussite du déploiement propre à chaque site et une purge du cache, à 06:35 PST, chaque page d’accueil portait un div de widget, une balise de script Turnstile et la clé de site, et la politique en ligne de chaque zone listait l’origine des challenges dans frame-src. Un envoi au formulaire de ce site avec un jeton CSRF bidon est revenu en 403, mais c’est la vérification CSRF qui le refuse, et cela ne prouve rien sur Turnstile. La vérification Turnstile elle-même, je l’ai testée à 06:43 PST : un envoi portant un jeton CSRF valide tiré de la page en ligne et sans jeton Turnstile est revenu en 403 avec le message « error: Verification failed. Reload the page and try again. », et le même envoi avec un jeton Turnstile bidon est revenu en 403 lui aussi. Le secret est donc bien sur le service, et la vérification s’exécute après CSRF. Sur le site du studio, un envoi sans jeton et un envoi avec un jeton bidon sont tous deux revenus en 403, et un envoi déclenchant le honeypot est revenu en 200, sans rien enregistrer ni envoyer.70
La vérification dans le navigateur. Dans un vrai Chromium piloté par Playwright, le widget s’est affiché sur les deux pages, le script Turnstile s’est chargé, et le champ caché cf-turnstile-response contenait un jeton de 730 caractères sur les deux pages en moins de six secondes.70 Cela prouve moins qu’il n’y paraît. La page de test de Cloudflare avertit que « Automated testing suites (like Selenium, Cypress, or Playwright) are detected as bots by Turnstile » (les suites de tests automatisées, comme Selenium, Cypress ou Playwright, sont détectées comme des bots par Turnstile), et seul siteverify décide si un jeton passe.67 Ce que la vérification prouve, c’est que le widget fonctionne sous les deux politiques et remplit son champ. Ce que je n’ai pas fait, c’est le seul test qui compte le plus pour un lecteur de cet article : une personne qui remplit le formulaire et reçoit une réponse. Ce test envoie un e-mail, c’est donc à moi de le faire, et je ne l’ai pas encore fait.
7. À quoi sert encore la limite de débit
Turnstile a remplacé les limites de débit en tant que porte. Il ne les a pas remplacées en tant que clôture. Une vérification anti-bot répond à la question « qui » ; il faut encore quelque chose pour répondre à « combien », car chaque soumission qui atteint la vérification avec un jeton non vide de taille valide coûte désormais un appel à l’API siteverify de Cloudflare, et chaque envoi qui passe coûte toujours un e-mail.12 Le guide côté serveur de Cloudflare lui-même range la limitation de débit parmi ses recommandations pour l’appel à siteverify : « Rate limit to protect against validation flooding. » (Limitez le débit pour vous protéger des inondations de validations.)60 Mes deux limites restent : la règle d’edge à 3 envois par 10 secondes par adresse et par centre de données, et le limiteur de l’application à cinq envois par minute et par adresse.917
La règle d’edge mérite une description honnête maintenant que je sais comment elle compte. Cloudflare expose clairement sa conception : « Cloudflare tracks request rates by maintaining separate counters for each unique combination of values in a rule’s characteristics » (Cloudflare suit les débits de requêtes en tenant des compteurs séparés pour chaque combinaison unique de valeurs des caractéristiques d’une règle), et « Cloudflare does not support global rate limiting counters across the entire network. » (Cloudflare ne prend pas en charge de compteurs de limitation de débit globaux sur l’ensemble du réseau.)6 Une adresse qui atteint ma zone par deux centres de données obtient deux quotas, et les envois du scanner du 2 novembre sont bien arrivés par deux ; c’est mon explication la plus solide du silence de la règle, compatible avec les totaux par minute mais non prouvée par eux.5 Ajoutez la réserve de Cloudflare sur la précision, à savoir que les règles « are not designed to allow a precise number of requests to reach your origin server » (ne sont pas conçues pour laisser atteindre votre serveur d’origine un nombre précis de requêtes), et la meilleure description de la règle est celle d’un budget nominal, non d’un plafond prouvé : 3 envois de formulaire par 10 secondes par adresse et par centre de données, l’équivalent nominal de 18 par minute, appliqué de façon approximative.489 Sur l’offre Free, c’est aussi la seule règle de limitation de débit à laquelle j’ai droit, avec une période fixe de 10 secondes et un comptage par IP uniquement.48
Un détail de l’offre Free compte si vous étiez tenté, comme je l’ai été, de faire de la limite de débit elle-même le challenge. La page des paramètres de Cloudflare indique que sur les offres Free, Pro et Business, une règle de limitation de débit dotée d’une action de challenge applique toujours un étranglement au lieu de tenir une durée, et que « When visitors pass a challenge, their corresponding request counter is set to zero. » (Quand des visiteurs réussissent un challenge, leur compteur de requêtes correspondant est remis à zéro.)71 Un challenge sur le POST XHR du formulaire échouerait en outre pour la raison exposée en section 6.63 La règle d’edge reste donc un blocage, et le challenge vit dans le widget.
Le limiteur de l’application a besoin de la correction qu’appelle sa fuite : un stockage de compteur avec incrément atomique, pour que deux envois ne puissent jamais lire le même compte, et un chemin d’échec qui ne répartit pas le compte entre processus. Le code actuel, une lecture KV suivie d’une écriture distincte avec un repli propre à chaque processus, n’offre ni l’un ni l’autre : c’est donc un changement de stockage, pas un correctif d’une ligne.173 Avec Turnstile comme condition supplémentaire à chaque soumission susceptible d’atteindre la boîte de réception, un envoi que la fuite laisse passer doit encore porter un jeton que Cloudflare accepte : la fuite coûte donc du volume et de l’argent, et la décision de qui peut poster ne repose plus sur le compte. Un limiteur est un budget. Un budget qui se trompe de 47 en une seule vague est un mauvais budget, mais il n’a jamais été un garde.
Les recommandations de l’OWASP sur l’authentification formulent la même superposition à leur façon. Elles traitent un CAPTCHA « as a defense-in-depth control to make brute-force attacks more time-consuming and expensive, rather than as a preventative. » (comme un contrôle de défense en profondeur destiné à rendre les attaques par force brute plus longues et plus coûteuses, plutôt que comme une mesure préventive.)72 Et leurs recommandations sur le credential stuffing notent que « Most tools used for these types of attacks will make direct POST requests to the server and read the responses, but will not download or execute JavaScript » (La plupart des outils utilisés pour ce type d’attaque envoient directement des requêtes POST au serveur et lisent les réponses, mais ne téléchargent ni n’exécutent de JavaScript).50 Mes deux scanners ont bien récupéré les pages du site, la voie la plus probable par laquelle ils ont obtenu des jetons CSRF valides. Un client qui soumet le formulaire sans exécuter le widget n’a pas de jeton Turnstile à envoyer, et les deux formulaires refusent désormais un envoi qui n’en a pas : sur ce site, un envoi portant un jeton CSRF valide et sans jeton Turnstile est revenu en 403, tout comme un envoi sans jeton sur le site du studio.70
La liste de contrôle
Pour un formulaire de contact derrière Cloudflare, dans l’ordre où je procéderais aujourd’hui :
- Placez une vérification anti-bot sur le formulaire et vérifiez chaque jeton sur le serveur. Selon les mots de Cloudflare : « The client-side widget alone does not protect your forms. » (Le widget côté client ne protège pas à lui seul vos formulaires.)60 Vérifiez aussi le nom d’hôte et l’action dans la réponse, comme le conseille la même page, pour qu’un jeton créé ailleurs ne passe pas, et tenez les domaines locaux à l’écart de la sitekey de production, car « Cloudflare recommends that sitekeys used in production do not allow local domains » (Cloudflare recommande que les sitekeys utilisées en production n’autorisent pas les domaines locaux).606667
- Échouez en position fermée. Un jeton absent, trop long, rejeté ou invérifiable est un refus, car « Tokens can be forged. » (Les jetons peuvent être falsifiés.)6012
- Réinitialisez le widget après chaque soumission. Les jetons sont à usage unique et valables 300 secondes : une nouvelle tentative sans réinitialisation est une tentative avec un jeton consommé.6069
- Autorisez l’origine des challenges dans les deux directives CSP. Cloudflare demande
script-srcetframe-srcsurhttps://challenges.cloudflare.com, et une politique limitée àdefault-src 'self'bloque l’iframe.6212 - Ne placez pas de managed challenge sur un formulaire qui poste en XHR ou en fetch. Cloudflare indique que la page de challenge « fails when the browser expects a non-HTML response » (échoue quand le navigateur attend une réponse non HTML), et un envoi HTMX est ce genre de requête.63
- Renvoyez une erreur qu’une personne peut voir. Mon refus CSRF était un 403 JSON que le formulaire HTMX n’affichait jamais, si bien qu’une personne munie d’un jeton expiré aurait cliqué sur Envoyer sans rien voir, et ma première correction écoutait sur le formulaire alors que HTMX déclenche l’événement de remplacement sur la cible de la réponse, un frère du formulaire : testez donc le refus dans un navigateur.1268
- Gardez le honeypot devant la vérification anti-bot. Il a intercepté 42 envois sur 52 sur le site de mon studio, pour le prix d’un champ caché et d’un
if.725 - Gardez la limite de débit, et dimensionnez-la comme un plafond de coût, pas comme une porte. Cloudflare la compte par adresse et par centre de données, et le scanner du 2 novembre est arrivé par deux, la raison la plus probable pour laquelle elle ne s’est jamais déclenchée, même si des totaux par minute ne peuvent pas le prouver.65
- Adossez le limiteur de l’application à un stockage qui incrémente de façon atomique. Le mien lisait le compte et le réécrivait dans une requête distincte, le remettait à un quand sa seconde lecture échouait, se rabattait sur la mémoire d’un seul processus quand KV échouait, et a laissé passer 47 et 9 envois au-delà de son propre plafond ; les journaux ne peuvent pas dire quel chemin a fui.173
- Ne comptez pas sur une règle de user agent pour arrêter un scanner. Les deux scanners envoyaient une chaîne Chrome ordinaire, au moins 69 322 fois le 30 octobre et 9 725 fois le 2 novembre.3241
- Comptez les e-mails chez le fournisseur de messagerie, pas au formulaire. Mon gestionnaire répond 200 sur ses chemins d’échec, et l’événement
email.sentde Resend signifie seulement que « the API request was successful » (la requête d’API a réussi) ; le champlast_eventd’un e-mail récupéré dit ce qui s’est passé ensuite.242873 - Interrogez les analyses dans le mois. Les jeux de données adaptatifs de Cloudflare conservent « at least 31 days of data on every plan » (au moins 31 jours de données quelle que soit l’offre).27
- Recomptez une fois la fenêtre close. Mes notes écrites quelques minutes après la vague du 30 octobre sous-estimaient les envois de formulaire, parce que Cloudflare n’avait pas fini d’ingérer les dernières minutes.16
- Considérez un blocage d’adresse comme un nettoyage. Le mien est arrivé environ 50 minutes après le premier envoi de formulaire le 30 octobre et 8 minutes après le début du scan le 2 novembre, et aucun envoi d’e-mail, ni l’un ni l’autre jour, n’est survenu après la minute du blocage : 101 avant lui le 30 octobre, et 26 avant lui et 5 pendant sa minute le 2 novembre.1192
Ce qui pourrait changer
Ce sont des conditions, pas des prévisions. Chacune dit ce qui devrait se produire et ce que je ferais alors.
Si un scanner se met à réussir les managed challenges. Les vérifications de Turnstile s’exécutent dans le navigateur et cherchent des traces d’automatisation ; un outil qui piloterait un navigateur complet assez bien pour les réussir en volume ferait du widget un coût de plus pour lui, pas un mur. L’indice serait des envois de formulaire portant des jetons acceptés par siteverify, depuis des adresses qui envoient aussi des chaînes d’injection dans l’en-tête User-Agent. Batchelder en a écrit la version honnête pour les honeypots en 2007 : « If and when they do, we can switch back to CAPTCHAs. » (Si et quand ils y parviennent, nous pourrons revenir aux CAPTCHA.)49 Ma solution de repli serait la même dans l’esprit, un challenge plus fort, et en attendant le honeypot et les limites de débit tiennent toujours.
Si j’ajoute une vérification à l’edge avec pre-clearance. Cloudflare recommande la pre-clearance de Turnstile pour les requêtes qui ne peuvent pas afficher de page de challenge, et un cookie cf_clearance permet à un visiteur de « bypass WAF Challenges on all subsequent requests on the zone, including API requests » (contourner les challenges du WAF sur toutes les requêtes suivantes vers la zone, y compris les requêtes d’API).6563 Si je l’adopte, l’edge pourra soumettre le POST du formulaire lui-même à un challenge sans casser HTMX pour un visiteur dont le cookie détient une autorisation valide au niveau du challenge ou au-dessus, et la page CSP de Cloudflare ajoute une exigence pour ce mode : « your connect-src directive must include ‘self’. » (votre directive connect-src doit inclure ‘self’.)62 Le cookie arrive « in addition to the default Turnstile token » (en plus du jeton Turnstile par défaut), et siteverify reste obligatoire : le challenge à l’edge serait donc une seconde vérification devant celle de mon serveur, et non un substitut.65 Je ne l’ai pas fait, et l’article décrit la conception widget plus siteverify qui est en ligne.
Si j’exporte le journal Resend pour les deux fenêtres. Chaque nombre d’e-mails de cet article est un plafond, car un 200 du formulaire est un envoi, ou un échec auquel mon gestionnaire répond aussi 200.24 Le point de terminaison de récupération de Resend renvoie un last_event pour chaque e-mail, comme delivered, et sa liste d’événements distingue « sent - The email was sent successfully. » (sent : l’e-mail a bien été envoyé.) de « delivered - Resend successfully delivered the email to the recipient’s mail server. » (delivered : Resend a bien remis l’e-mail au serveur de messagerie du destinataire.)7374 Avec cet export, 101 et 31 deviendraient des chiffres exacts, et ils ne pourraient que baisser.
Si une personne envoie le spam à la main. Turnstile demande si un navigateur et, parfois, une personne sont présents. Il ne demande pas pourquoi. Une personne payée pour coller un argumentaire dans des formulaires de contact peut le passer, tout comme les expéditeurs de génération de leads du site de mon studio s’il y a une personne derrière eux. Pour ce cas, les défenses sont les anciennes : une limite de débit pour le volume, et mon propre jugement dans la boîte de réception.
Si le goutte-à-goutte Tor exige une réponse jour par jour. La liste de sorties que j’ai vérifiée est celle que j’ai récupérée le 3 novembre, et le service CollecTor du Tor Project conserve les listes passées.5355 Si je l’interroge pour les huit jours, l’attribution à Tor reposera sur les listes des jours d’arrivée des envois, et non sur le code de Cloudflare plus la liste du 3 novembre. Cloudflare propose aussi, sur toutes ses offres, l’Onion Routing, qui sert les utilisateurs de Tor directement par le réseau Tor ; Cloudflare dit qu’avec lui, « Human Tor users and bots can be distinguished by our Onion services, such that interactive challenges are only served to malicious bot traffic. » (nos services Onion peuvent distinguer les utilisateurs humains de Tor des bots, de sorte que les challenges interactifs ne sont servis qu’au trafic de bots malveillants.)75 Je ne l’ai pas activé, et avant de le faire, je voudrais mesurer le trafic Tor du site du studio après Turnstile.
Points clés
Si vous gérez un formulaire de contact :
- Un jeton CSRF et une limite de débit réunis laissent quand même un scanner vous écrire. Mes deux scanners détenaient des jetons valides, et mon limiteur a laissé passer 101 et 31 envois.12
- Vérifiez sur le serveur le jeton d’une vérification anti-bot, échouez en position fermée et réinitialisez le widget après chaque requête ; le widget seul « does not protect your forms. » (ne protège pas vos formulaires.)60
- Testez votre limiteur sous charge et en situation d’échec, pas une requête à la fois. Un compteur qui lit puis écrit, avec un repli propre à chaque processus, a laissé passer 18 envois dans une minute réglée pour en autoriser cinq.117
- Gardez le honeypot. C’est le filtre le moins coûteux de la pile, et il a intercepté 80,8 % de ces soumissions sur le site de mon studio, en huit jours, avant que le gestionnaire ne vérifie une adresse, n’enregistre une ligne ou n’envoie un e-mail.725
Si vous gérez l’edge du site :
- Sachez ce que compte votre limite de débit. Chez Cloudflare, chaque règle compte par centre de données, et un scanner qui arrive par deux obtient deux compteurs.65
- Sondez vos propres règles et lisez ensuite les événements de pare-feu. Onze événements de pare-feu issus de ma sonde m’en ont appris davantage sur la règle qu’une journée de raisonnements sur les raisons de son silence.18
- Ne soumettez pas un POST XHR ou fetch à une page de challenge ; Cloudflare documente que cela échoue, et recommande la pre-clearance pour ce cas.63
- Attendez-vous à ce que les règles de user agent n’attrapent que les outils honnêtes. Les scanners envoyaient ici une chaîne de navigateur ; mon test curl, lui, a déclenché des challenges.3239
Si c’est vous qui lisez la boîte de réception :
- Quand la boîte se remplit, regardez l’expéditeur avant l’objet. Trois de mes flux de courrier partagent une même adresse d’expédition, et c’est pourquoi ma première hypothèse était fausse.23
- Traitez les 200 d’un formulaire comme un plafond d’e-mails, pas comme un décompte, tant que le journal du fournisseur de messagerie ne dit pas autre chose.2428
- Lisez les messages enregistrés avant de couper la notification. Le message d’un vrai client est un jour resté une semaine sans être lu, et c’est à cause de ce message que le formulaire du studio m’envoie des e-mails.25
Questions fréquentes
Pourquoi mon formulaire de contact reçoit-il du spam malgré Cloudflare ?
Très probablement parce que rien devant le formulaire ne demande à chaque soumission si c’est une personne qui l’a envoyée. Les règles personnalisées de Cloudflare comparent des adresses, des réseaux, des chemins et des chaînes user-agent, et ses limites de débit comptent les requêtes par adresse et par centre de données ; un scanner qui envoie un user agent de navigateur, charge d’abord la page et poste à un rythme modéré les franchit toutes. Sur mon site, deux scanners ont fait exactement cela et fait passer 101 et 31 soumissions, et le spam du site de mon studio est arrivé, sur les huit jours mesurés, surtout à raison d’un envoi par adresse, ce qu’aucun compte par adresse ne peut attraper.96127
Une limite de débit arrête-t-elle les bots ?
Une limite de débit borne la vitesse d’une adresse ; elle ne distingue pas un bot d’une personne. La documentation de Cloudflare elle-même indique que ses règles « are not designed to allow a precise number of requests to reach your origin server » (ne sont pas conçues pour laisser atteindre votre serveur d’origine un nombre précis de requêtes) et que les compteurs ne sont pas partagés entre centres de données. Ma règle d’edge ne s’est déclenchée sur aucun des deux scanners, et mon limiteur applicatif, réglé sur cinq par minute, en a laissé passer 18 en une minute : son compteur est une lecture suivie d’une écriture distincte, une seconde lecture qui échoue le remet à un, et un repli propre à chaque processus prend le relais quand le stockage échoue ; chacun de ces chemins peut laisser passer des envois en trop.48641173
Un champ honeypot suffit-il ?
Il suffit pour les bots qui remplissent le champ caché, et sur le site de mon studio, cela représentait 80,8 % de ces soumissions : le mien a intercepté 42 envois sur 52 en huit jours, dont, d’après l’heure de leur arrivée, chaque envoi venant d’un nœud de sortie Tor. Il ne peut rien contre un expéditeur qui laisse le champ caché vide, et les 10 qu’il a laissé passer, enregistrés et donc 10 e-mails au plus, ont fait exactement cela.720 Ned Batchelder, en décrivant la technique en 2007, le disait déjà : « In theory, it is possible for a spambot to defeat any of these measures. » (En théorie, un spambot peut déjouer n’importe laquelle de ces mesures.)49
Turnstile ou CAPTCHA : quelle est la différence ?
Un CAPTCHA classique demande au visiteur de résoudre une énigme. Turnstile exécute « a series of small non-interactive JavaScript challenges to gather signals about the visitor or browser environment » (une série de petits challenges JavaScript non interactifs pour recueillir des signaux sur le visiteur ou l’environnement du navigateur), et en mode managed, il n’affiche une case à cocher que lorsque le risque du visiteur l’exige, avec « No images or text to decipher. » (aucune image ni aucun texte à déchiffrer.)2158 Cloudflare dit que ses challenges n’utilisent pas de « CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters. » (énigmes CAPTCHA ni tests visuels comme sélectionner des objets ou taper des caractères déformés.)76 Dans les deux cas, il faut encore que le serveur vérifie le résultat ; pour Turnstile, c’est l’appel à siteverify.60
Pourquoi la protection CSRF n’arrête-t-elle pas le spam ?
Une défense CSRF correctement mise en œuvre sert à empêcher un autre site de faire soumettre votre formulaire par le navigateur d’un visiteur. Mon jeton est plus faible que cela : il est sans état et signé, valable une heure, et lié ni à une session, ni à un cookie, ni à une adresse ; il prouve donc seulement que le client détient un jeton émis par mon site dans l’heure, pas d’où vient la requête. Ni l’un ni l’autre n’arrête un client qui charge votre page et soumet le formulaire lui-même, car charger la page est précisément la façon dont un client obtient un jeton valide, et les deux scanners en ont récolté ; la vérification a refusé quatre de leurs envois sur l’ensemble des deux vagues.1512
Comment distinguer un scanner d’une personne dans les journaux ?
Regardez tout ce que l’adresse a fait, pas seulement le formulaire. Mes deux scanners ont parcouru des milliers de chemins, glissé des chaînes d’injection SQL dans les chemins et dans l’en-tête User-Agent, envoyé un user agent Chrome pour la plupart des requêtes et une prétention Googlebot pour certaines, que la règle des bots vérifiés de Cloudflare n’a pas acceptée, et soumis le formulaire par rafales de quelques dizaines à quelques centaines par minute.31324441 Les charges utiles temporelles comme sleep(15) et waitfor delay '0:0:15' appartiennent à des familles de sondes documentées.3377 Une personne envoie un seul message, depuis une page qu’elle a réellement lue, et ne demande pas à votre serveur de dormir.
Turnstile collecte-t-il des données personnelles ?
Cloudflare ne dit pas qu’il n’en collecte aucune. Son addendum de confidentialité pour Turnstile nomme les signaux qu’il traite, « such as client IP address, TLS Fingerprint, User-Agent Header and Sitekey and associated origin » (comme l’adresse IP du client, l’empreinte TLS, l’en-tête User-Agent, ainsi que la sitekey et l’origine associée), indique que la finalité est « solely to detect and block bots » (uniquement de détecter et de bloquer les bots), et précise que Cloudflare est responsable du traitement de ces signaux afin d’améliorer sa détection des bots. Sa présentation dit que Turnstile « does not access, store, or transmit user communications, form entries, or other page inputs. » (n’accède pas aux communications des utilisateurs, aux saisies de formulaire ni aux autres saisies de la page, et ne les stocke ni ne les transmet.)2221 Il voit donc des données de connexion, pas ce qu’une personne tape.
Sur ce site, à lire aussi. Le mode auto de Claude Code n’est pas une frontière de sécurité défend le même argument à propos d’un autre contrôle : un classifieur qui juge chaque action n’est pas le mur. Agents.txt n’est pas un contrôle d’accès trace la ligne entre déclarer une politique aux bots et la faire respecter. Fatigue d’alerte : 82 e-mails en un jour de mon moniteur de sécurité traite le scanner du 2 novembre du point de vue de mon moniteur : un blocage huit secondes après son premier constat, huit minutes après le début du scan, et zéro e-mail à ce sujet. Ce que je lance avant de dormir est un autre genre de routine d’exploitation, une vérification nocturne de 15 000 pages pour leur statut, leur temps de réponse et leur cache, et Ce qui casse vraiment quand des agents IA tournent sans supervision recense les modes de défaillance des sessions d’agents sans supervision, le genre d’automatisation qui fait tourner mon infrastructure.
Sources
Notes de l’auteur sur les données : chaque chiffre issu de mes propres systèmes provient de l’API GraphQL Analytics de Cloudflare pour les deux zones (les jeux de données de requêtes et d’événements de pare-feu), de la table des messages du site du studio, du journal de mon moniteur et du code des deux formulaires, le tout sauvegardé le 3 novembre 2026 et transformé en registre par script. Les heures sont en UTC sauf mention PDT ou PST. Aucune adresse, aucun identifiant de règle ou de liste, aucune clé ni aucun chemin de système de fichiers issus de ces éléments n’apparaît dans cet article.
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour la zone de ce site, POST du scanner du 30 octobre vers le formulaire de contact par minute et par statut d’edge, interrogées le 3 novembre 2026 sur la fenêtre close : 1 065 envois, dont 101 ont reçu 200, 830 ont reçu 429, 130 ont reçu 422 et 4 ont reçu 403, tous parvenus à l’application ; premier envoi et premier 200 dans la minute de 18:00, pic de 208 envois dans la minute de 18:03, maximum de 200 en une minute 18 (18:06), huit minutes avec plus de cinq 200, dernier 200 à 18:36 et dernier envoi parvenu à l’application à 18:37. Déduit dans le registre : 101 envois d’e-mails avant le blocage, le premier envoi environ 46 minutes avant que je signale la vague et 49,88 minutes avant le blocage, le dernier envoi parvenu à l’application 11,88 minutes avant le blocage. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour la zone de ce site, POST du scanner du 2 novembre vers le formulaire de contact par minute et par statut d’edge, interrogées le 3 novembre 2026 : 500 envois, dont 31 ont reçu 200, 31 ont reçu 429 et 438 ont reçu 403 pendant ou après la minute du blocage ; 62 sont parvenus à l’application ; premier envoi dans la minute de 18:58, pic de 32 envois parvenus à l’application dans la minute de 19:02, maximum de 200 en une minute 10 (19:03), dernier 200 dans la minute de 19:04 ; une moyenne de 8,86 envois par minute sur les sept minutes allant du premier envoi parvenu à l’application, dans la minute de 18:58, au dernier, dans la minute de 19:04 ; sur les 31 envois d’e-mails, 26 dans les minutes précédant la minute du blocage et 5 dans la minute du blocage elle-même, 19:04, la minute où l’adresse a été inscrite sur la liste à 19:04:01, que des tranches d’une minute ne permettent pas d’ordonner par rapport à cette seconde ; aucun après. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Analyse de l’auteur dans le registre : pour chaque minute, le plus petit de cinq et du nombre d’envois vus par le limiteur de l’application (les 200, les 429 et les 403 CSRF de l’application ; pas les 422, que FastAPI refuse avant l’exécution du limiteur), additionné sur les minutes. 30 octobre : au plus 54 envois pour un limiteur exact contre 101 mesurés, 47 au-dessus, sur 935 envois vus par le limiteur. 2 novembre : au plus 22 contre 31, 9 au-dessus, sur 62. Un plafond contrefactuel, pas une mesure ; les envois mesurés sont les chiffres du formulaire ci-dessus. ↩↩↩↩↩↩↩
-
Données de l’auteur. Événements de pare-feu de Cloudflare pour l’adresse du 2 novembre, interrogés le 3 novembre 2026 (un jeu de données échantillonné, utilisé pour savoir quelle règle a agi et quand, jamais pour les volumes) : le premier événement à 18:57:11, issu de la règle des chemins d’exploit, le premier événement de la règle de liste de blocage à 19:04:03 ; chaque ligne un blocage, aucune un managed challenge ; 310 lignes sur le formulaire de contact, chacune un blocage, la première à 19:04:05 ; et aucun événement de la règle de limitation de débit pour l’adresse. ↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur, addendum du registre. Analyses de requêtes de Cloudflare pour les POST de formulaire des deux scanners, regroupées par centre de données Cloudflare, minute et statut, interrogées le 3 novembre 2026 : le 2 novembre de 18:56 à 19:05:00, fin de la requête, si bien que le décompte ne contient que le premier envoi de la minute de 19:05, 44 envois par Amsterdam et 41 par Londres ; dans la minute de 19:02, 18 par Londres (4 envoyés, 14 refusés par l’application) et 14 par Amsterdam (5 envoyés, 9 refusés) ; le plus grand nombre en une minute dans un centre de données, 19, était celui d’Amsterdam dans la minute de 19:04, dont 15 refusés à l’edge après le blocage. Le 30 octobre, les 1 065 envois sont tous passés par Paris. ↩↩↩↩↩↩↩↩
-
Cloudflare, “How Cloudflare determines the request rate”, documentation du WAF de Cloudflare, consultée le 3 novembre 2026 à 14:08:57 UTC. Des compteurs séparés par combinaison de caractéristiques ; des compteurs non partagés entre centres de données, sauf ceux d’un même emplacement géographique ;
cf.colo.idcomme caractéristique obligatoire ; pas de compteurs globaux. ↩↩↩↩↩↩↩ -
Données de l’auteur. Analyses de requêtes de Cloudflare pour la zone du site du studio, POST vers le formulaire de contact par heure, adresse, pays et user agent, de 17:00 UTC le 26 octobre à 13:59:50 UTC le 3 novembre 2026 : 52 envois, tous servis en 200, depuis 35 adresses ; 23 envois et 23 adresses portant le code pays Tor de Cloudflare (44,2 % et 65,7 %) ; 32 adresses avec exactement un envoi (91,4 %), dont toutes les adresses Tor ; les trois adresses récurrentes avec 13 (Royaume-Uni, une heure, le user agent SQLSpider), 2 (Pologne, une heure) et 5 (États-Unis, un nouveau user agent à chaque fois) ; user agents Tor 18 Android et 5 Chrome de bureau, 21 désignant Chrome 81 ou plus ancien ; user agents hors Tor 10 Chrome de bureau de 125 à 135, 6 Firefox de 128 à 138 et 13 autres. Le pare-feu de la zone n’a journalisé aucun événement sur un POST vers le formulaire dans la fenêtre. Les déclenchements du honeypot sont les envois moins les lignes enregistrées : 42, soit 80,8 %. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. La table des messages de contact du site du studio, lue le 3 novembre 2026 à 14:04 UTC : 108 lignes du 6 juin à 07:24 UTC le 3 novembre, dont 10 dans la fenêtre de la requête ; par jour comme dans le tableau de la section 5 ; 32 lignes en octobre sur 21 jours sur 31, 1,03 par jour, jamais plus de trois en un jour ; les neuf lignes à $50,000,000 sous un même nom d’expéditeur avec neuf adresses d’expéditeur ; 43 lignes depuis le commit de la notification et 65 avant. ↩↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. La configuration des zones des deux sites, exportée via l’API de Cloudflare le 3 novembre 2026 : la règle de limitation de débit de ce site (version 1, dernière mise à jour le 2026-10-30 à 18:49:53 UTC ; expression telle que citée ; caractéristiques centre de données et adresse cliente ; 3 requêtes par période de 10 secondes ; délai d’atténuation de 10 secondes, l’équivalent nominal de 18 par minute par adresse et par centre de données) et ses cinq règles personnalisées (un blocage de la liste du compte et de sept ASN, un blocage des chemins d’exploit, un contournement pour les bots vérifiés et deux managed challenges sur le formulaire de contact et les chemins d’administration, par chaîne user-agent et par threat score, tous deux actifs lors de l’export, celui fondé sur le threat score désactivé plus tard le même jour) ; les règles du site du studio, des mêmes types, avec le challenge appliqué à tout le site et un blocage des chemins d’API qui exempte le formulaire, ainsi qu’une règle de limitation de débit à l’échelle du site comptée par centre de données et par adresse sur les requêtes des clients qui ne sont pas des bots vérifiés. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. Événements de pare-feu de Cloudflare pour l’adresse du 30 octobre, interrogés le 3 novembre 2026 (3 932 lignes échantillonnées, sous la limite de la requête, utilisées uniquement pour l’attribution et la chronologie) : le premier événement à 17:59:53, issu de la règle des chemins d’exploit, qui a agi dès la première minute du scan ; chacun des 111 événements échantillonnés avant 18:49:53 issu de cette règle ; le premier événement de la règle de liste de blocage à 18:49:53 ; aucun événement de la règle de limitation de débit et aucun événement sur le formulaire de contact pour l’adresse. ↩↩↩↩↩↩↩↩↩↩↩
-
Cloudflare, “cf.threat_score”, référence des champs du Ruleset Engine, consultée le 3 novembre 2026 à 16:12:44 UTC. La description du champ : un score qui allait autrefois de 0 à 100, et « Now, the threat score is always 0 (zero). » ↩↩↩
-
Relevé de l’auteur de ce qui a été mis en ligne le 3 novembre 2026, rédigé d’après les sorties d’outils de la session : les deux widgets (mode managed, créés via l’API de Cloudflare vers 14:13 UTC), les modifications de CSP sur les deux zones, le vérificateur de ce site et l’ordre de son gestionnaire (échec en position fermée, CSRF puis Turnstile puis l’envoi, fragments d’erreur 403, réinitialisation du widget après chaque requête HTMX), ses dix nouveaux tests (10 réussis, plus un onzième venu avec la vérification du nom d’hôte), l’ordre du gestionnaire du site du studio (honeypot, puis Turnstile, puis validation, enregistrement et notification), et les vérifications locales avec les clés de test de Cloudflare ; sur les deux sites, le vérificateur refuse un jeton absent, ou de plus de 2 048 caractères, sans appeler siteverify (d’après le code). Les deux sites ont été déployés entre 14:32 et 14:36 UTC. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 30 octobre, par minute et dans un balayage de toutes les zones de mon compte, interrogées le 3 novembre 2026 : 76 429 requêtes vers ce site de la minute de 17:59 à celle de 18:51 (53 tranches), le même total dans les deux requêtes ; 69 484 avant la minute du blocage, soit une moyenne de 1 389,7 par minute, un pic de 3 219 à 18:36, 5 234 après la minute du blocage, aucune servie ; par résultat, 49 513 servies (64,8 %), 18 990 refusées par l’application (24,8 %, dont 10 019 GET 418 de la barrière à agents du site), 2 339 erreurs 520 d’origine (3,1 %), 5 587 bloquées à l’edge (7,3 %) ; 50,88 minutes de la première minute du scan au blocage ; 387 GET vers kiradex.app, tous servis en 200, et aucune autre zone touchée. ↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 2 novembre, par minute, par chemin, par user agent et dans un balayage de toutes les zones, interrogées le 3 novembre 2026 : deux GET à 07:49 UTC, 11,1 heures avant le scan ; 10 907 requêtes de la minute de 18:56 à celle de 19:17 (22 tranches), 10 909 avec les GET matinaux, le même total dans quatre requêtes ; 29 requêtes dans la première minute, 637 dans la minute la plus chargée avant le blocage (18:57), 1 107 dans la minute la plus chargée (19:07), 300 dans la dernière ; 2 986 avant la minute du blocage (une moyenne de 373,2 par minute), 7 445 après, aucune servie ; par résultat, 1 388 servies (12,7 %), 1 473 refusées par l’application (13,5 %, dont 420 GET 418), 116 erreurs 520 d’origine (1,1 %), 7 930 bloquées à l’edge (72,7 %) ; 8,02 minutes de la première minute du scan au blocage ; 368 GET vers kiradex.app et aucune autre zone touchée. ↩↩↩↩↩↩↩↩↩↩↩
-
Lecture du code par l’auteur. Le module CSRF de ce site tel que déployé le 30 octobre et le 2 novembre : un jeton fait d’un horodatage, d’une valeur aléatoire et d’une signature HMAC-SHA256 sur les deux ; valide si la signature correspond et si l’horodatage date de moins de 3 600 secondes ; lié ni à une session, ni à un cookie, ni à une adresse. Lu dans le source le 3 novembre 2026. ↩↩↩↩
-
Données de l’auteur. Le registre construit par script à partir des résultats de requêtes sauvegardés (484 lignes, chacune avec sa source et sa méthode) et son livre de codes, 3 novembre 2026, y compris les règles indiquant quelle couche a répondu à chaque statut, la règle selon laquelle les événements de pare-feu donnent l’attribution et les horodatages et les jeux de données de requêtes les chiffres, la règle selon laquelle seul un 200 prouve un jeton CSRF valide, et le tableau des écarts entre mes notes du jour et les requêtes du 3 novembre (les notes du 30 octobre ont été écrites à 11:58 PDT, avant que Cloudflare n’ait ingéré les dernières minutes, et sous-estiment les envois de formulaire, les 422, les envois de partage de dessin et les requêtes de l’heure). ↩↩↩↩↩↩↩↩
-
Lecture du code par l’auteur. Le limiteur de débit du formulaire de ce site tel que déployé le 30 octobre et le 2 novembre : par défaut cinq envois de formulaire par adresse cliente et par fenêtre, une fenêtre de 60 secondes indexée sur la minute d’horloge, une lecture KV du compteur, une seconde lecture dans l’incrément qui compte une lecture échouée comme zéro, et une écriture KV distincte de ce compte plus un, un 429 quand le compte lu atteint la limite, et un repli sur une liste en mémoire propre au processus quand l’appel KV échoue. Lu dans le source le 3 novembre 2026. ↩↩↩↩↩↩↩↩↩↩
-
Données de l’auteur, addendum du registre. Ma sonde de la règle de limitation de débit de ce site le 3 novembre 2026 depuis ma propre machine : à 14:36:49 UTC, dix POST sans jeton espacés de 0,4 seconde (quatre 422, puis six 429) ; à 14:38:03 UTC, huit avec tous les champs (trois 403 de l’origine, puis cinq 429 avec le corps « error code: 1015 ») ; les événements de pare-feu de Cloudflare pour le formulaire de 14:35 à 14:40 UTC, récupérés à 14:40:36 UTC : 11 événements, chacun un blocage de la règle de limitation de débit. ↩↩↩↩
-
Données de l’auteur. Le journal de mon moniteur pour le 2 novembre 2026, de 10:40 à 11:32 PST, et ses enregistrements de constats et de blocages, sauvegardés le 3 novembre : une exécution lancée à 10:57:00 PST avec une fenêtre de détection se terminant à 18:57:22 UTC et aucun constat, les requêtes de l’adresse dans cette fenêtre comprises entre 29 et 666 d’après les données par minute ; l’exécution suivante à 11:02:46 PST ; le premier constat à 11:03:53 PST (une inondation de requêtes critique, 1 914 requêtes comptées pour l’adresse), 7,88 minutes après la première minute du scan ; l’adresse ajoutée à la liste de blocage du compte à 11:04:01 PST ; six constats au total, tous intégrés au récapitulatif quotidien et aucun envoyé par e-mail. Le moniteur ne tournait pas le 30 octobre ; je l’ai réarmé le 31 octobre, et son détecteur de trafic est arrivé plus tard. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩
-
Analyse de l’auteur. Rapprochement par tranche horaire des lignes enregistrées du site du studio et des données horaires de POST, 3 novembre 2026 : aucune ligne enregistrée dans une heure ne comptant que des envois Tor ; les 10 dans des heures comportant un envoi hors Tor ; 22 des 23 envois Tor dans des heures sans ligne enregistrée, le 23e dans une heure partagée avec l’adresse américaine de génération de leads ; les cinq lignes de génération de leads de la fenêtre dans les cinq heures où cette adresse a posté. Une inférence par heure, pas une jointure sur un identifiant de requête. ↩↩↩↩↩↩↩
-
Cloudflare, “Overview”, présentation de la documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. « Cloudflare’s smart CAPTCHA alternative » ; intégration sans trafic Cloudflare ; les challenges JavaScript non interactifs et la description de la preuve de travail et de la preuve d’espace ; l’affirmation selon laquelle Turnstile n’accède pas aux saisies de formulaire, ne les stocke ni ne les transmet. ↩↩↩↩↩↩
-
Cloudflare, “Turnstile Privacy Addendum”, dernière mise à jour le 18 juin 2025, consulté le 3 novembre 2026 à 14:08:57 UTC. Les signaux que traite Turnstile, leur finalité déclarée et le rôle de Cloudflare comme responsable du traitement pour l’amélioration de la détection des bots, tous cités. ↩↩↩
-
Journal de bord (Captain’s Log) de l’auteur, 2 novembre 2026 : ma question à la session d’agent qui fait tourner mon infrastructure, à savoir si c’était elle qui envoyait les e-mails, les trois flux de courrier qui partagent une même adresse d’expédition, les objets « Contact Form: » des e-mails portant les charges utiles du scanner, et ma note selon laquelle les envois de formulaire du scanner « ran about nine a minute » (tournaient à environ neuf par minute). ↩↩↩↩↩
-
Lecture du code par l’auteur. Le gestionnaire de contact et le gabarit du formulaire de ce site tels que déployés le 30 octobre et le 2 novembre : un formulaire HTMX (
hx-postvers le chemin du formulaire) avec le jeton CSRF rendu dans la page ; le décorateur du limiteur avant le gestionnaire ; la vérification CSRF, qui lève un 403, avant l’envoi ; l’envoi via Resend avec l’objet « Contact Form: » et le nom de l’expéditeur ; le fragment de succès pour un envoi accepté ; et deux chemins d’échec, une configuration de messagerie manquante et un envoi échoué ou sans id, qui renvoient le fragment d’erreur avec le statut par défaut de FastAPI, 200. Pas de champ honeypot. Lu dans le source le 3 novembre 2026. ↩↩↩↩↩↩↩↩↩ -
Lecture du code et de l’historique du dépôt par l’auteur pour le site du studio tel que déployé avant le 3 novembre 2026 : la branche honeypot du gestionnaire de contact (un champ caché
websitenon vide reçoit le fragment de succès, sans rien enregistrer ni envoyer ; c’est la première vérification du gestionnaire, avant la vérification d’adresse, l’insertion et la notification), présente depuis le commit qui a construit le formulaire le 5 juin 2026 ; les attributstabindex="-1"etaria-hiddendu champ caché ; pas de jeton CSRF ; la notification au mieux, ajoutée le 21 septembre 2026 à 17:46:47 UTC dans un commit dont le message dit « a paying customer’s message sat unread in D1 for a week » ; et un limiteur de débit en mémoire propre à chaque processus pour les clients anonymes, indexé sur l’adresse cliente et la classification du client (le niveau de bot que le limiteur attribue d’après le user agent et l’adresse), sur tous les chemins sauf les contrôles de santé et les fichiers statiques. Lu dans le dépôt le 3 novembre 2026. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Journal de bord (Captain’s Log) de l’auteur, 30 octobre 2026, écrit à 11:58 PDT : mon signalement de la vague à 11:46 PDT, le blocage manuel et la règle de limitation de débit de 11:48 à 11:49 PDT, et mon test à 11:52 PDT, cités. Ses chiffres ont été relevés alors que Cloudflare ingérait encore les dernières minutes de la vague, et cet article utilise à la place les requêtes du 3 novembre. ↩↩↩
-
Cloudflare, “GraphQL API - Limits”, documentation de l’API GraphQL Analytics, consultée le 3 novembre 2026 à 14:08:57 UTC. Les jeux de données adaptatifs « retain at least 31 days of data on every plan ». ↩↩
-
Resend, “Event Types”, documentation des webhooks de Resend, consultée le 3 novembre 2026 à 14:14:47 UTC.
email.sent« Occurs whenever the API request was successful. Resend will attempt to deliver the message to the recipient’s mail server. » (Se produit dès que la requête d’API a réussi ; Resend tentera de remettre le message au serveur de messagerie du destinataire.) ;email.deliveredpour une remise réussie. ↩↩↩ -
Données de l’auteur. Analyses de requêtes de Cloudflare pour toutes les autres adresses ayant soumis le formulaire de contact de ce site dans la requête sur huit jours, hors les deux scanners et mon adresse de test, interrogées le 3 novembre 2026 : trois adresses (Corée du Sud, Japon, Inde), chacune avec exactement deux 200 en un jour, les 27, 28 et 29 octobre. ↩
-
RIPE NCC, RIPEstat network-info, whois et as-overview pour l’adresse du 30 octobre et son AS, consultés le 3 novembre 2026 à 14:10:34 et 14:11:34 UTC : AS34534, titulaire « BULLIONET FBW NETWORKS SAS », une allocation enregistrée avec le pays FR. ↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 30 octobre regroupées par chemin, méthode et statut, interrogées le 3 novembre 2026 ; la requête a renvoyé sa limite de 3 000 lignes, couvrant 74 125 des 76 429 requêtes, si bien que les chiffres par chemin sont des bornes inférieures : au moins 2 881 chemins distincts et au moins 2 680 requêtes portant une charge utile d’injection SQL dans le chemin sur au moins 810 chemins distincts ; 320 POST vers le chemin de partage de dessin, tous refusés (422 ou 400), et aucun depuis une autre adresse en huit jours ; les variantes du chemin de partage avec charge utile citées en section 2, servies en 404. ↩↩↩↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 30 octobre regroupées par user agent, interrogées le 3 novembre 2026 ; la requête a renvoyé sa limite de 500 lignes, couvrant 76 095 des 76 429 requêtes, si bien que chaque chiffre ici est une borne inférieure : au moins 69 322 requêtes avec une chaîne Chrome 141 Windows ordinaire, au moins 2 755 avec une prétention Googlebot, et au moins 3 154 avec une charge utile d’injection SQL sous au moins 396 chaînes distinctes. ↩↩↩↩↩↩
-
PortSwigger Web Security Academy, “SQL injection cheat sheet”, consulté le 3 novembre 2026 à 14:10:34 UTC. Les temporisations pour Oracle (
dbms_pipe.receive_message), Microsoft (WAITFOR DELAY), PostgreSQL (pg_sleep) et MySQL (SLEEP) ; la forme conditionnelleIF(condition, SLEEP(10), 'a');SELECT @@versionpour Microsoft et MySQL ; leFROM duald’Oracle. ↩↩↩↩↩ -
Projet sqlmap, “time_blind.xml”, les définitions de charges utiles aveugles temporelles de l’outil, consultées le 3 novembre 2026 à 14:12:27 UTC. Contient des charges utiles
SLEEP,PG_SLEEP,WAITFOR DELAYetDBMS_PIPE.RECEIVE_MESSAGE. Cité pour les familles, pas comme l’outil utilisé par l’un ou l’autre scanner. ↩ -
PortSwigger Web Security Academy, “Blind SQL injection”, consulté le 3 novembre 2026 à 14:10:34 UTC. Pourquoi une temporisation dans une requête retarde la réponse HTTP, cité. ↩
-
OWASP Automated Threats to Web Applications, “OAT-014 Vulnerability Scanning”, consulté le 3 novembre 2026 à 14:13:41 UTC. Le résumé, cité. ↩
-
Données de l’auteur, addendum du registre. Analyses de requêtes de Cloudflare pour les POST de formulaire des deux scanners regroupées par statut d’edge et statut d’origine, interrogées le 3 novembre 2026 : le 30 octobre, 395 des 830 réponses 429 portent un statut d’origine 429 et les 435 autres aucun statut enregistré ; le 2 novembre, 16 des 31 réponses 429 portent un 429 d’origine et 15 aucun statut enregistré. Aucun 429 ne porte un statut propre à l’edge là où celui de l’origine a été enregistré. ↩↩
-
Test de l’auteur, 3 novembre 2026 : une application FastAPI de dix lignes exécutée dans l’environnement du site lui-même (FastAPI 0.128.0), avec un décorateur de type limiteur sur le point de terminaison. Un POST sans champ obligatoire a renvoyé 422 sans que l’enveloppe soit jamais appelée ; un POST complet a atteint l’enveloppe. ↩
-
Données de l’auteur. Événements de pare-feu et analyses de requêtes de Cloudflare pour ma propre adresse de test le 30 octobre 2026, interrogés le 3 novembre : trois événements de la règle de limitation de débit, le premier à 18:52:38 UTC, avec un user agent de navigateur, et 24 managed challenges de la règle de challenge sur le user agent pour mes envois curl. Exclus de tous les chiffres de l’incident. ↩↩↩↩↩
-
RIPE NCC, RIPEstat network-info, whois et as-overview pour l’adresse du 2 novembre et son AS, consultés le 3 novembre 2026 à 14:10:34 et 14:11:34 UTC : AS24875, titulaire « NOVOSERVE-AS NovoServe B.V. », une route enregistrée au nom de NovoServe B.V., pays NL. La description de la sous-affectation nomme un particulier et n’est pas reprise ici. ↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 2 novembre regroupées par user agent, interrogées le 3 novembre 2026, complètes (73 chaînes couvrant les 10 909 requêtes) : 9 725 requêtes avec une chaîne Chrome 141 Windows ordinaire, 942 avec une prétention Googlebot, 54 charges utiles d’injection SQL distinctes dans 149 requêtes, 3 chaînes de rupture de guillemets dans 78, 12 jetons marqueurs
@@dans 12, et 2 autres chaînes ordinaires. ↩↩↩↩↩↩ -
Données de l’auteur. Les tableaux des user agents des deux jours, construits par le script du registre le 3 novembre 2026 : la chaîne Chrome 141 Windows ordinaire et la chaîne Googlebot sont identiques octet pour octet le 30 octobre et le 2 novembre. ↩
-
Rejeu par l’auteur, le 3 novembre 2026, des propres détecteurs de mon moniteur sur des données Cloudflare complètes pour 18:58:23 à 19:03:23 UTC le 2 novembre, la fenêtre de l’exécution lancée à 11:02:46 PST, exécuté à 15:55:04 UTC et sauvegardé sous forme d’enregistrement de rejeu avec les commandes exactes et la sortie complète des détecteurs ; cinq lignes de l’addendum du registre en sont tirées par script : 2 143 requêtes depuis l’adresse dans le constat d’inondation de requêtes, 1 055 requêtes refusées dans le constat de scanner, et un constat d’inondation du formulaire à partir de 39 envois dont 23 refusés, de gravité élevée, qui va au récapitulatif quotidien et n’envoie ni e-mail ni blocage. Les 1 914 de l’exécution en direct, dans une fenêtre qui se terminait à l’instant où l’exécution interrogeait, sont ce qu’a enregistré le propre journal de mon moniteur, et l’exécution en direct n’a levé aucun constat d’inondation du formulaire. La modification des fenêtres faite le même jour : chaque fenêtre en direct se termine désormais 90 secondes avant l’exécution et commence là où la précédente s’était arrêtée, avec un simple retour de cinq minutes en arrière quand l’enregistrement précédent manque ou est ancien. ↩↩↩
-
Données de l’auteur. Analyses de requêtes de Cloudflare pour l’adresse du 2 novembre regroupées par chemin, interrogées le 3 novembre 2026, complètes (2 318 lignes couvrant les 10 909 requêtes) : 2 173 chemins distincts, et 778 requêtes sur 542 chemins distincts portant une charge utile d’injection SQL. ↩↩
-
OWASP Web Security Testing Guide v4.2, “Testing for SQL Injection”, consulté le 3 novembre 2026 à 14:12:12 UTC. Les techniques fondées sur les erreurs et sur les temporisations, citées. ↩
-
Relevé de l’auteur de la troisième modification du 3 novembre 2026, faite après une relecture du brouillon de cet article : à 16:12 UTC, les domaines autorisés des deux widgets ramenés au site et à son hôte www, localhost retiré ; les listes de noms d’hôte par défaut des deux vérificateurs ramenées aux deux mêmes noms d’hôte, le développement local définissant sa propre liste ; les tests de ce site vérifient que localhost est refusé par défaut (11 réussis) ; le site du studio redéployé, et, exécuté en local avec la clé de test toujours valide de Cloudflare, un jeton factice atteignait encore la validation quand le nom d’hôte fictif de cette clé figurait dans la liste et était refusé quand il n’y figurait pas. À 16:13 UTC, les deux règles de managed challenge fondées sur le threat score, une par zone, ont été désactivées, avec le motif dans leur description ; les challenges sur le user agent restent. Vérifications en ligne après cette modification : le site du studio à 16:15 UTC (widget, envois sans jeton et avec jeton bidon en 403, honeypot en 200), ce site à 16:18 UTC (widget, envoi sans jeton en 403) ; aucune n’exerce la règle du nom d’hôte. ↩↩↩
-
Cloudflare, “Custom rules”, documentation du WAF de Cloudflare, consultée le 3 novembre 2026 à 14:10:34 UTC. Ce que font les règles personnalisées, cité, et les cinq règles de l’offre Free avec toutes les actions sauf Log. ↩
-
Cloudflare, “Rate limiting rules”, documentation du WAF de Cloudflare, consultée le 3 novembre 2026 à 14:08:57 UTC. Les paramètres des règles ; la réserve sur la précision et le délai de mise à jour des compteurs, cités ; le tableau de disponibilité, qui donne à l’offre Free une période de comptage de 10 secondes, un délai d’atténuation de 10 secondes, un comptage par IP et une seule règle. ↩↩↩↩↩↩
-
Ned Batchelder, “Stopping spambots with hashes and honeypots”, créé le 21 janvier 2007, consulté le 3 novembre 2026 à 14:10:34 UTC. La définition du honeypot, la raison pour laquelle les bots tombent dans les champs invisibles, la taxonomie rejeu et remplissage de formulaires, et ses deux phrases sur les bots qui déjouent les mesures et le retour aux CAPTCHA, toutes citées. La page présente la technique comme la façon dont il protège son site ; elle ne prétend pas l’avoir inventée. ↩↩↩↩↩↩
-
OWASP Cheat Sheet Series, “Credential Stuffing Prevention Cheat Sheet”, consulté le 3 novembre 2026 à 14:10:34 UTC. Ne pas s’appuyer sur une seule limite de volume prévisible ; un faible volume par adresse qui déjoue les listes de blocage et la limitation de débit ; des outils qui postent directement sans exécuter de JavaScript, tous cités. ↩↩↩
-
Cloudflare, “Cloudflare HTTP headers”, documentation Cloudflare Fundamentals, consultée le 3 novembre 2026 à 14:14:20 UTC. Les codes spéciaux de l’en-tête CF-IPCountry, dont « T1 - Used for clients using the Tor network. » ↩
-
Cloudflare, “IP Access rules parameters”, documentation du WAF de Cloudflare, consultée le 3 novembre 2026 à 14:14:20 UTC. « T1: Tor exit nodes (country name: Tor) ». ↩
-
The Tor Project, “Bulk Tor Exit Exporter”, check.torproject.org, consulté le 3 novembre 2026 à 14:10:34 UTC. La description de la liste, citée, et le renvoi vers CollecTor pour les données passées ; la liste elle-même, récupérée au même moment depuis https://check.torproject.org/torbulkexitlist, comptait 1 239 adresses. ↩↩
-
Données de l’auteur, addendum du registre. Chaque adresse distincte ayant soumis le formulaire du site du studio dans la requête sur huit jours, répartie selon le code pays de Cloudflare, comparée par script à la liste globale des sorties du Tor Project récupérée à 14:10:34 UTC le 3 novembre 2026 (1 239 adresses) : 23 des 23 adresses codées T1 sur la liste, et 0 des 12 autres. Seulement la liste du 3 novembre ; les jours des faits n’ont pas été vérifiés dans l’archive. ↩
-
The Tor Project, “Sources”, Tor Metrics, consulté le 3 novembre 2026 à 14:10:34 UTC. Les listes de sorties TorDNSEL « containing the IP addresses of relays that it found when exiting through them » (contenant les adresses IP des relais trouvés en sortant par eux), et leur archive. ↩↩
-
OWASP Automated Threats to Web Applications, “OAT-017 Spamming”, consulté le 3 novembre 2026 à 14:10:34 UTC. Le résumé, les autres noms dont le spam de formulaire, et l’exclusion des abus de formulaire vers e-mail visant des destinataires non prévus, cités. ↩↩
-
Analyse de l’auteur. Les 108 messages enregistrés du site du studio triés selon des règles fixes, la première correspondance l’emportant (correspondance sur le nom d’expéditeur pour la question du prix et les appâts à gains, sur le texte du message pour le reste, une ligne classée à la main comme vraie demande), 3 novembre 2026 : les chiffres du tableau de la section 5 ; les 25 langues de la question du prix sur la table et 9 en octobre, selon ma lecture de chaque phrase fixe, avec le croate (ou le bosnien) et l’indonésien (ou le malais) comme attributions serrées ; chaque ligne de la fenêtre de la requête classée comme spam. ↩↩↩↩↩
-
Cloudflare, “Turnstile widgets”, documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. Les modes managed, non interactif et invisible et ce que voit un visiteur dans chacun, cité pour le mode managed ; la condition de politique de confidentialité du mode invisible ; l’origine challenges.cloudflare.com. ↩↩↩
-
Cloudflare, “Cloudflare Turnstile plans”, documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. Free et Enterprise ; jusqu’à 20 widgets ; challenges illimités ; utilisation sans autres services Cloudflare, cité. ↩
-
Cloudflare, “Validate the token”, documentation de la validation côté serveur de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. L’obligation d’appeler siteverify ; les jetons falsifiés ; le point de terminaison et ses champs
secret,responseetremoteip; la validité de 300 secondes et l’usage unique ; le maximum de 2 048 caractères ; la vérification de l’action et du nom d’hôte ; le tableau des codes d’erreur et ses deux descriptions de l’expiration ; la limitation de débit contre les inondations de validations. Tous les passages cités proviennent de cette page. ↩↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Cloudflare, “Cloudflare Turnstile - Easy CAPTCHA Alternative”, page produit, consultée le 3 novembre 2026 à 14:08:57 UTC. « Turnstile never harvests data for ad retargeting. » ↩
-
Cloudflare, “Content Security Policy”, documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC.
script-srcetframe-srcpour https://challenges.cloudflare.com ; l’approche fondée sur un nonce ; l’exigenceconnect-src 'self'pour la pre-clearance, citée. ↩↩↩ -
Cloudflare, “Interstitial Challenge Pages”, documentation de Cloudflare Challenges, consultée le 3 novembre 2026 à 14:08:57 UTC. Comment une page de challenge retient la requête ; les managed challenges recommandés pour la plupart des règles WAF ; la limite AJAX et XHR (fetch) et la recommandation de Turnstile Pre-clearance, toutes deux citées. ↩↩↩↩↩↩
-
Cloudflare, “Detect a Challenge Page response”, documentation de Cloudflare Challenges, consultée le 3 novembre 2026 à 14:10:34 UTC. L’en-tête
cf-mitigatedpositionné sur challenge et le content-type text/html de tout challenge, cités. ↩ -
Cloudflare, “Clearance”, documentation de Cloudflare Challenges, consultée le 3 novembre 2026 à 14:10:34 UTC. Le cookie
cf_clearanceet la pre-clearance, cités : le cookie est délivré en plus du jeton à usage unique par défaut, Siteverify reste obligatoire, et un cookie contourne les challenges jusqu’au niveau d’autorisation défini sur le widget. ↩↩↩ -
Relevé de l’auteur de la deuxième modification du 3 novembre 2026, faite le même matin : le site du studio déployé à 15:19 UTC et vérifié en ligne à 15:22 UTC (widget, script, clé de site et origine des challenges dans le
frame-srcen ligne ; un envoi sans jeton et un envoi avec jeton bidon refusés en 403 ; un envoi déclenchant le honeypot servi en 200) ; ce site construit à partir de 15:20 UTC, son cache purgé, et vérifié en ligne à 15:25 UTC (les mêmes vérifications de page, un envoi sans jeton refusé en 403, puis un envoi portant un jeton CSRF valide tiré de la page en ligne et sans jeton Turnstile de nouveau refusé en 403, ce qui montre que le formulaire échoue en position fermée après le déploiement mais ne peut pas exercer la vérification du nom d’hôte) ; les deux vérificateurs exigent désormais que le nom d’hôte de la réponse de Cloudflare soit l’un de ceux du site (une réponse sans nom d’hôte est refusée) et, quand Cloudflare renvoie une action, que ce soit celle du widget lui-même ; les onze tests de ce site réussissent, le nouveau acceptant le nom d’hôte du site et refusant un autre nom d’hôte, une autre action et un nom d’hôte absent ; sur le site du studio, exécuté en local avec la clé de test toujours valide de Cloudflare, qui répond avec un nom d’hôte fictif et sans action, un jeton factice atteignait la validation quand ce nom d’hôte figurait dans la liste et était refusé quand il n’y figurait pas. ↩↩↩↩ -
Cloudflare, “Test your Turnstile implementation”, documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. Les clés de test toujours valide et toujours en échec et le jeton factice ; les secrets de production rejettent les jetons factices ; les suites de tests automatisées détectées comme des bots, cités. ↩↩↩↩↩
-
Relevé de l’auteur de la quatrième modification du 3 novembre 2026, faite après une deuxième relecture du brouillon de cet article : le gestionnaire de la première version pour l’événement before-swap de HTMX, qui laisse passer un fragment 403 malgré le comportement par défaut de HTMX consistant à ne pas insérer une réponse 4xx, était attaché au formulaire de contact, alors que HTMX déclenche cet événement sur la cible du remplacement, un frère du formulaire, si bien qu’un fragment 403 n’était jamais affiché ; la relecture l’a repéré en lisant le HTMX embarqué et le gabarit. Le correctif est un écouteur sur le body de la page limité à l’id de la cible de réponse, et le test de la page d’accueil vérifie qu’il est rendu (11 tests réussis). Une fois le déploiement vérifié à 16:18 UTC, une vérification dans le navigateur à 16:21 UTC dans le Chromium de Playwright a remplacé le jeton de 730 caractères du widget par une chaîne bidon, rempli les champs obligatoires avec des valeurs fictives et soumis le formulaire via HTMX ; la cible de réponse a affiché le fragment d’erreur, « error: Verification failed. Reload the page and try again. », et aucun fragment de succès, et rien n’a été envoyé. Non fait : la soumission de bout en bout par une personne. ↩↩
-
Cloudflare, “Embed the widget”, documentation de Turnstile, consultée le 3 novembre 2026 à 14:08:57 UTC. Le script api.js, le conteneur du widget, le champ caché
cf-turnstile-responsecréé dans un formulaire, etturnstile.reset(widgetId)pour les widgets expirés, cité. ↩↩↩ -
Relevé de l’auteur des vérifications en ligne du 3 novembre 2026, chacune après la réussite du déploiement de son propre site, et après la purge du cache : à 06:35:24 et 06:35:54 PST, un div de widget, une balise de script et la clé de site sur chaque page d’accueil, l’origine des challenges dans chaque
frame-srcen ligne, et sur ce site un envoi avec tous les champs et un jeton CSRF bidon refusé en 403 par la vérification CSRF ; à 14:43 UTC, sur ce site, un envoi portant un jeton CSRF valide de 108 caractères tiré de la page en ligne et sans jeton Turnstile refusé en 403 avec « Verification failed. Reload the page and try again. », et le même envoi avec un jeton Turnstile bidon refusé en 403 ; sur le site du studio, un envoi sans jeton et un envoi avec jeton bidon refusés en 403 et un envoi déclenchant le honeypot servi en 200 sans rien enregistrer ni envoyer ; dans le Chromium de Playwright à 14:39:50 et 14:40:04 UTC, le widget affiché (602 par 73 et 557 par 71 pixels), l’objet API de Turnstile chargé, et le champ de réponse caché contenant un jeton de 730 caractères sur les deux pages en moins de six secondes. Non fait : la soumission de bout en bout par une personne. ↩↩↩ -
Cloudflare, “Rate limiting parameters”, documentation du WAF de Cloudflare, consultée le 3 novembre 2026 à 14:08:57 UTC. Les noms des paramètres ; sur les offres Free, Pro et Business, une action de challenge applique toujours un étranglement, paraphrasé ; un challenge réussi remet le compteur à zéro, cité. ↩
-
OWASP Cheat Sheet Series, “Authentication Cheat Sheet”, consulté le 3 novembre 2026 à 14:10:34 UTC. Le CAPTCHA comme contrôle de défense en profondeur, cité. ↩
-
Resend, “Retrieve Sent Email”, référence de l’API de Resend, consultée le 3 novembre 2026 à 14:14:47 UTC. Le champ
last_eventd’un e-mail récupéré, par exemple « delivered ». ↩↩ -
Resend, “View and manage emails”, documentation de Resend, consultée le 3 novembre 2026 à 14:15:12 UTC. La liste des événements d’e-mail, dont « sent » et « delivered », citée, et « queued » réservé aux Broadcasts et aux Batches. ↩
-
Cloudflare, “Onion Routing and Tor support”, documentation de Cloudflare Network, consultée le 3 novembre 2026 à 14:08:57 UTC. L’Onion Routing sur toutes les offres et la distinction entre utilisateurs humains de Tor et bots, citée. ↩
-
Cloudflare, “Challenges”, documentation de Cloudflare Challenges, consultée le 3 novembre 2026 à 14:08:57 UTC. « Cloudflare does not use CAPTCHA puzzles or visual tests like selecting objects or typing distorted characters. » ↩
-
OWASP Community, “Blind SQL Injection”, consulté le 3 novembre 2026 à 14:10:34 UTC. L’injection aveugle temporelle, qui repose sur la mise en pause de la base de données, avec
WAIT FOR DELAYetpg_sleep()comme exemples. ↩