Cada control por IP que lanzamos se podía saltar con un solo header
Nuestro contador de bloqueo de cuenta limitaba los logins fallidos a cinco. Nuestro rate limiter frenaba a los clientes abusivos. Ambos usaban como clave la dirección IP del cliente, que es la clave obvia. Ambos se podían saltar por completo, y el bypass era un solo header HTTP que tú mismo podías fijar.
Este es el porqué. Cuando tu aplicación corre detrás de un reverse proxy, y la nuestra corre detrás de nginx en Kubernetes, la conexión TCP que ve la aplicación no viene del usuario. Viene del proxy. Cada request llega con la misma dirección de socket: el ingress. Así que la IP real del cliente tiene que viajar en un header, X-Forwarded-For, que el proxy fija y la aplicación lee. ASP.NET Core tiene middleware exactamente para esto: lee X-Forwarded-For y reescribe la IP remota de la conexión para que coincida, de modo que el resto de tu código, incluido tu rate limiter, vea al cliente verdadero.
El problema es que X-Forwarded-For es solo un header. Cualquiera puede enviar uno. Si la aplicación lo confía incondicionalmente, y la nuestra lo hacía, confiando en él desde cualquier origen, entonces el valor lo controla el atacante. Así que el ataque no es ingenioso, es una línea:
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
Cambia el header en cada request y la aplicación cree que cada intento viene de un cliente completamente nuevo. El contador de bloqueo, con esa IP como clave, nunca llega a cinco para ningún valor individual. El bucket del rate limiter está fresco cada vez. Puedes hacer fuerza bruta contra una contraseña todo el día, y cada defensa por IP que teníamos cuenta felizmente hasta uno. Un control cuya clave es un valor que fija el atacante no es un control.
El arreglo suena trivial: confiar en X-Forwarded-For solo cuando viene de tu propio proxy. Es trivial en principio y enredado en la práctica, y Kubernetes es donde vive el enredo.
No puedes simplemente dejar de leer el header. Si lo haces, cada request parece venir del ingress, así que ahora todos tus clientes comparten una IP y los controles por IP se rompen en la dirección contraria: un abusador dispara el bloqueo para todo el mundo. Tienes que leer X-Forwarded-For, pero creerle solo cuando el request realmente vino de un proxy en el que confías.
El middleware de forwarded headers de ASP.NET confía por origen: una lista de IPs de proxy conocidas, o redes de proxy conocidas dadas como CIDRs. La jugada ingenua es fijar la IP del pod del ingress. En AKS esa IP no es estable. Los pods del ingress se reprograman y se reescalan, y sus direcciones cambian con ellos, así que una IP fijada se queda obsoleta y o rompe el tráfico legítimo o, si dejas un fallback viejo, reabre el agujero. Lo que sí es estable es la subred de la que salen los pods. Así que confías en el CIDR de la subred de los nodos, no en ninguna dirección individual. Cada salto que puede fijar legítimamente el header vive en ese rango, y a nada fuera de él se le cree.
Luego está la cuenta. El middleware recorre la lista de X-Forwarded-For desde la derecha, pelando un salto confiable a la vez, y lo que queda después de los saltos confiables se toma como el cliente. Recorre un salto de más y te sales de tu proxy y entras en la parte del header que escribió el atacante. Así que el número de saltos que pelas tiene que ser igual al número de proxies que realmente añaden al header, exactamente. En nuestra ruta hay uno: nginx. El load balancer de Azure delante de él es layer 4. Reenvía TCP, no parsea HTTP, y no añade ninguna entrada a X-Forwarded-For. Así que el límite de forwards correcto es uno. No el valor por defecto, no "unos cuantos por si acaso". Uno, porque un proxy toca ese header.
Ese par, confiar en la subred de los nodos y pelar exactamente un salto, es todo el arreglo. La IP de cliente que el middleware te entrega es ahora el valor que nginx escribió desde el socket real, y las entradas inyectadas por el atacante quedan a su izquierda, ignoradas, porque dejamos de recorrer antes de llegar a ellas.
La parte transferible no son los números, es que son números sobre tu topología específica. X-Forwarded-For no es un dato, es delegación de confianza: leerlo es decir "le creo a quien haya fijado esto". Esa creencia tiene que estar anclada a los saltos exactos de tu ruta de requests, ni más ancha ni más estrecha. Confía demasiado ancho y es falsificable. Confía demasiado estrecho y colapsas a todos tus clientes en el proxy. Y la cuenta está acoplada a tu infraestructura, así que el día que pongas un CDN delante de todo, habrás añadido un salto, y el límite de forwards que ayer era correcto ahora se queda corto en uno. El arreglo no es una constante mágica. Es: cuenta los proxies de tu ruta, confía en esos y solo en esos, y vuelve a contar cada vez que la ruta cambie.
Contar saltos de proxy correctamente no tiene glamour, es fácil equivocarse, y decide si tus rate limits significan algo en absoluto. Authagonal lleva esa contabilidad por ti, para que tu bloqueo cuente al cliente y no a un header.