Par Juste Thales Gnimavo (CEO, ZeroSuite) et Claude Opus 5.5 (Claude Code)
Déblo Duo est une application d'interprétation en temps réel : chacun parle sa langue et entend l'autre dans la sienne. Pourquoi Déblo Duo existe présente le produit. L'un de ses modes s'appelle Scan and Talk : votre écran affiche votre code, la personne en face le scanne, et votre téléphone sonne comme pour un appel entrant. Ce mode est ouvert aux inconnus par conception, il lui faut donc des plafonds contre l'abus. Cet article couvre deux sessions du 6 novembre 2026 : celle qui devait ajouter ces plafonds, et celle qui a suivi, après que la première a découvert que tous nos plafonds par IP pouvaient être remis à zéro par un seul en-tête forgé.
Mesurer avant de plafonner
Le prompt en file demandait des plafonds par adresse IP sur Scan and Talk : combien d'appareils nouveaux peuvent frapper à la porte d'un même propriétaire depuis une même adresse, et combien de refus une adresse peut accumuler avant d'être mise en sourdine. Le prompt supposait aussi quelque chose sur notre infrastructure : que chaque requête atteignait notre application à travers Cloudflare, et qu'on pouvait donc croire l'en-tête où Cloudflare écrit l'adresse du visiteur, CF-Connecting-IP.
Claude a vérifié cette hypothèse avant d'écrire un seul plafond. Il a envoyé une requête directement à notre serveur d'origine, sans passer par Cloudflare. L'origine a répondu 200, avec la même empreinte de build qu'à travers Cloudflare.
Cela voulait dire deux choses. Notre serveur répondait à quiconque l'atteignait en direct. Et notre application lisait CF-Connecting-IP quel qu'en soit l'expéditeur, parce que rien entre Internet et l'application ne vérifiait qui avait le droit de poser cet en-tête. Une requête envoyée en direct à l'origine avec une adresse inventée dans cet en-tête était comptée sous l'adresse inventée. Chaque plafond par IP du produit repartait de zéro pour chaque nouvelle valeur.
Ajouter un plafond par IP par-dessus aurait été de la décoration. La session a donc commencé par l'adresse elle-même.
Une seule fonction décide d'où vient une requête
Le correctif est une seule fonction, client_ip(request), dans un module nouveau. Elle répond à une question : quel saut nous a réellement remis cette requête, et peut-on croire ce que ce saut affirme ?
- Elle prend le saut qui a atteint notre reverse proxy. Notre proxy ajoute l'adresse qui l'a contacté à l'extrémité droite de
X-Forwarded-For: l'entrée la plus à droite est donc écrite par notre propre infrastructure, pas par le client. - Elle n'honore
CF-Connecting-IPque si ce saut appartient aux plages d'adresses publiées par Cloudflare, et seulement si la valeur se lit comme une adresse IP. - Dans tous les autres cas, elle plafonne sur le saut lui-même.
Les plages Cloudflare sont copiées dans le code, IPv4 et IPv6, telles que publiées le 6 novembre. Nous l'avons préféré à un téléchargement au démarrage : aucune dépendance réseau au boot, et Cloudflare annonce ses changements longtemps à l'avance.
Un second correctif était sur la table, côté réseau plutôt que dans le code. C'est un changement d'infrastructure, de niveau 1 selon nos règles : il revient au CEO et se suit à part. Le code n'en dépend plus : les plafonds tiennent, quel que soit le chemin d'une requête.
Les plafonds de Scan and Talk reposent sur cette fonction. Ils se comptent par propriétaire et par adresse, pas par propriétaire seul, parce qu'un budget global par propriétaire laisserait un script le vider et bloquer les vrais clients du commerçant. Ils sont plus larges que les plafonds par appareil, parce que des clients derrière un même opérateur mobile partagent souvent une même adresse publique. Un appareil qui a déjà fait la queue chez ce propriétaire récemment n'est pas recompté : un vrai client qui rescanne garde sa place.
La preuve en production, première phase
Le code est parti en production à 11:02 UTC. À 11:03 UTC, Claude a envoyé deux requêtes : l'une directement à l'origine avec un CF-Connecting-IP forgé, l'autre à travers Cloudflare. Puis il a lu les compteurs.
Les deux requêtes étaient comptées sous l'adresse réelle de la machine de test. Aucun compteur pour l'adresse forgée, aucun pour l'adresse interne du proxy, ce qui montrait aussi que le chemin par Cloudflare honorait toujours l'en-tête.
Les plafonds ont ensuite été prouvés sur un compte de test du CEO, trois minutes plus tard. Des appareils aléatoires nouveaux depuis une même adresse ont été acceptés jusqu'au plafond, puis refusés avec 429 scan_rate_limited. Un appareil déjà connu de ce propriétaire pouvait encore rescanner et retrouvait la même demande. Après une série de refus depuis une même adresse, même un appareil connu venant de cette adresse était refusé. Rien n'a été débité. Les effets de bord : quelques demandes et une notification sur les appareils du compte de test. Les demandes restées sans réponse ont expiré au bout d'une minute.
Six lectures brutes, une seule fonction
La première session a corrigé les chemins de Duo, parce que sa fonction remplaçait la fonction interne de Duo. La même lecture brute existait ailleurs. Une seconde session, plus tard le même jour, dans une chaîne de sessions validée par Juste, a fait passer toutes les lectures restantes par client_ip(). Il y en avait six :
| Où | Avant | Ce que cela alimentait |
|---|---|---|
| chat, contrôle invité | CF-Connecting-IP brut | adresse transmise à Turnstile |
| chat, plafond invité | CF-Connecting-IP brut | le plafond du chat invité |
| voix, début d'appel | CF-Connecting-IP brut | lecture du budget vocal invité du jour |
| voix, fin d'appel | CF-Connecting-IP brut | incrément de ce même budget |
| formulaire de contact | première entrée de X-Forwarded-For | le plafond du formulaire de contact |
| attribution publicitaire | CF-Connecting-IP brut | l'adresse jointe aux événements de conversion |
Le formulaire de contact était le pire des six. Il croyait l'entrée la plus à gauche de X-Forwarded-For, celle qu'écrit le client. Celle-là était forgeable même à travers Cloudflare.
La paire voix était subtile d'une autre façon. Le début et la fin lisaient le même en-tête brut, ils étaient donc d'accord entre eux. Cohérents, et tous les deux forgeables. Ils calculent désormais la même clé avec la même fonction.
Une lecture est restée telle quelle, volontairement : un outil d'administration interne qui n'utilise l'adresse que pour écrire une entrée de journal d'audit, sans plafond ni décision.
La preuve en production, seconde phase
Le second déploiement était en ligne à 11:36 UTC. Pour prouver le chemin voix, Claude a mis le budget vocal invité de l'adresse réelle de la machine de test à l'état épuisé, puis a envoyé deux demandes d'appel : l'une directement à l'origine avec un CF-Connecting-IP forgé, l'autre à travers Cloudflare.
direct origin, forged CF-Connecting-IP : 402 {"detail":"guest_voice_daily_limit"}
via Cloudflare : 402 {"detail":"guest_voice_daily_limit"}Les deux étaient comptées sous l'adresse réelle. Avant le changement, la requête forgée aurait lu le budget vide de l'adresse inventée et obtenu un appel. Deux lectures des compteurs n'ont trouvé aucune clé pour l'adresse forgée. Le vrai compteur a ensuite été restauré.
L'incident pendant la preuve
Le log porte une section intitulée « Incident pendant la preuve (de mon fait) ». Elle est de Claude, et elle a sa place dans cet article.
Deux fois, l'étape qui mettait le budget à l'état épuisé a échoué, et Claude a sondé sans vérifier qu'elle avait fonctionné. La première tentative visait un port de base de données inaccessible de l'extérieur. La seconde rangeait une commande distante dans une variable de shell que le shell n'a pas découpée en arguments. Les deux ont échoué sans bruit. Les sondes ont donc obtenu ce qu'obtiendrait un invité sans limite : elles ont ouvert quatre vraies sessions vocales invitées.
Claude les a fermées toutes les quatre par la route normale de fin d'appel, en moins de 15 secondes. Toutes se sont terminées après 11 à 12 secondes, sans aucun crédit débité, vérifié en base. Le coût réel : quelques secondes d'une salle vocale vide et d'un worker dépêché, sans personne dans l'appel. Leurs 45 secondes ont été ajoutées au budget invité de l'adresse de test de Claude, et de personne d'autre.
La leçon est écrite dans le log telle qu'elle a été appliquée à la troisième tentative : « la mise en place passe dans un appel séparé, relue, avant toute sonde ». Une sonde lancée sur une mise en place non vérifiée ne teste pas le plafond. Elle teste l'état, quel qu'il soit, que la mise en place a laissé.
Ce qui reste ouvert
Deux chemins ne sont pas prouvés en production : le plafond du chat invité et le formulaire de contact. Le chat refuse une requête invitée sans jeton Turnstile valide avant même d'atteindre le compteur, donc une sonde scriptée ne va pas jusque-là. Le formulaire de contact incrémente son compteur après l'envoi d'un vrai e-mail à Juste : le prouver revient à lui écrire depuis une requête forgée. Les deux appellent la même fonction que le chemin voix prouvé plus haut. C'est une bonne raison de s'attendre à ce qu'ils se comportent bien. Ce n'est pas une preuve : ils restent ouverts dans le log, avec l'observation exacte que chacun attend.
Ce que nous en retenons
Le prompt avait raison sur ce qu'il fallait construire et tort sur le terrain où il le posait. Si la session avait cru son propre brief, elle aurait livré des plafonds soignés qu'un seul en-tête pouvait remettre à zéro. L'étape la moins chère de la journée a été une requête envoyée directement à notre propre serveur, avant d'écrire la moindre ligne.
L'incident est la même leçon, tournée vers nous. Une hypothèse non vérifiée, dans un brief comme dans une commande de mise en place, ne reste pas une hypothèse. Elle devient ce que le système fait ensuite.