Chaque contrôle par IP qu'on avait livré se contournait avec un seul header
Notre compteur de verrouillage de compte plafonnait les échecs de connexion à cinq. Notre rate limiter freinait les clients abusifs. Les deux se basaient sur l'adresse IP du client, ce qui est le choix évident. Les deux étaient totalement contournables, et le contournement tenait dans un seul header HTTP que vous pouviez définir vous-même.
Voici pourquoi. Quand votre appli tourne derrière un reverse proxy, et la nôtre tourne derrière nginx sur Kubernetes, la connexion TCP que voit l'appli ne vient pas de l'utilisateur. Elle vient du proxy. Chaque requête arrive avec la même adresse de socket : l'ingress. La vraie IP du client doit donc être transportée dans un header, X-Forwarded-For, que le proxy définit et que l'appli lit. ASP.NET Core a un middleware exactement pour ça : il lit X-Forwarded-For et réécrit l'IP distante de la connexion pour qu'elle corresponde, si bien que le reste de votre code, y compris votre rate limiter, voit le vrai client.
Le problème, c'est que X-Forwarded-For n'est qu'un header. N'importe qui peut en envoyer un. Si l'appli lui fait confiance sans condition, et la nôtre le faisait, en lui faisant confiance quelle que soit la source, alors la valeur est contrôlée par l'attaquant. L'attaque n'a donc rien d'astucieux, elle tient en une ligne :
curl -H "X-Forwarded-For: 10.0.0.1" https://.../login
curl -H "X-Forwarded-For: 10.0.0.2" https://.../login
curl -H "X-Forwarded-For: 10.0.0.3" https://.../login
Changez le header à chaque requête et l'appli croit que chaque tentative vient d'un client flambant neuf. Le compteur de verrouillage, indexé sur cette IP, n'atteint jamais cinq pour aucune valeur donnée. Le bucket du rate limiter est vierge à chaque fois. Vous pouvez brute-forcer un mot de passe toute la journée, et chacune de nos défenses par IP compte joyeusement jusqu'à un. Un contrôle indexé sur une valeur que l'attaquant définit n'est pas un contrôle.
Le fix a l'air trivial : ne faire confiance à X-Forwarded-For que venant de votre propre proxy. C'est trivial en principe et délicat en pratique, et c'est sur Kubernetes que le délicat se loge.
Vous ne pouvez pas juste arrêter de lire le header. Si vous le faites, chaque requête semble venir de l'ingress, tous vos clients partagent alors une seule IP et les contrôles par IP cassent dans l'autre sens : un seul abuseur déclenche le verrouillage pour tout le monde. Il faut lire X-Forwarded-For, mais ne le croire que quand la requête vient réellement d'un proxy de confiance.
Le middleware forwarded-headers d'ASP.NET fait confiance par source : une liste d'IP de proxies connues, ou des réseaux de proxies connus donnés en CIDR. Le réflexe naïf est d'épingler l'IP du pod ingress. Sur AKS, cette IP n'est pas stable. Les pods ingress se font replanifier et redimensionner, et leurs adresses tournent avec eux ; une IP épinglée finit donc obsolète et soit casse le trafic légitime, soit, si vous laissez traîner un vieux fallback, rouvre la faille. Ce qui est stable, c'est le sous-réseau d'où les pods tirent leurs adresses. On fait donc confiance au CIDR du sous-réseau des nœuds, pas à une adresse isolée. Chaque saut qui peut légitimement définir le header vit dans cette plage, et rien en dehors n'est cru.
Ensuite, il y a le compte. Le middleware parcourt la liste X-Forwarded-For depuis la droite, en épluchant un saut de confiance à la fois, et ce qui reste après les sauts de confiance est pris pour le client. Avancez d'un saut de trop et vous sortez de votre proxy pour tomber dans la partie du header écrite par l'attaquant. Le nombre de sauts que vous épluchez doit donc être exactement égal au nombre de proxies qui ajoutent réellement une entrée au header. Sur notre chemin, il y en a un : nginx. Le load balancer Azure devant lui est layer 4. Il fait suivre du TCP, il ne parse pas le HTTP, et il n'ajoute aucune entrée X-Forwarded-For. La bonne limite de forward est donc un. Pas la valeur par défaut, pas « quelques-uns pour être tranquille ». Un, parce qu'un seul proxy touche ce header.
Cette paire, faire confiance au sous-réseau des nœuds et éplucher exactement un saut, c'est tout le fix. L'IP client que le middleware vous tend est désormais la valeur que nginx a écrite depuis le vrai socket, et les entrées injectées par l'attaquant restent à sa gauche, ignorées, parce qu'on arrête d'avancer avant de les atteindre.
La partie transférable, ce ne sont pas les nombres, c'est que ce sont des nombres sur votre topologie à vous. X-Forwarded-For n'est pas de la donnée, c'est de la délégation de confiance : le lire, c'est dire « je crois celui qui l'a défini ». Cette croyance doit être épinglée aux sauts exacts de votre chemin de requête, ni plus large ni plus étroite. Trop de confiance et c'est spoofable. Pas assez et vous écrasez tous vos clients dans le proxy. Et le compte est couplé à votre infrastructure : le jour où vous mettez un CDN devant tout, vous avez ajouté un saut, et la limite de forward qui était correcte hier est désormais trop courte d'un cran. Le fix n'est pas une constante magique. C'est : comptez les proxies sur votre chemin, faites confiance à ceux-là et à eux seuls, et recomptez à chaque fois que le chemin change.
Compter correctement les sauts de proxy, c'est ingrat, facile à rater, et ça décide si vos rate limits veulent dire quoi que ce soit. Authagonal tient cette comptabilité pour vous, pour que votre verrouillage compte le client et pas un header.