📻 Versión en audio: si prefieres escuchar en vez de leer, el resumen del artículo está justo aquí arriba.
Esta semana Lauri Koskela publicó una investigación bien molesta: probó frameworks web de renombre con configuración casi por defecto y encontró que varios, incluidos Rails, Jetty y Spring Boot, le creen al header que manda el cliente antes que al que escribe su propio proxy. Traducido a criollo: la puerta de tu edificio firma quién entra, y la aplicación decide que la mejor credencial es una que el visitante escribió a mano en la calle.
De dónde sale la trampa
Los headers de proxy son la única forma decente de saber la IP real de un cliente cuando hay una capa intermedia. El estándar de facto son los X-Forwarded-*: los escribe el proxy y la app los lee. En 2014 el RFC 7239 propuso reemplazarlos con un header Forwarded único y elegante. Once años después ese reemplazo fracasó en la práctica: los proxies, CDN y middleware casi no lo usan ni lo tocan, mientras que algunos frameworks sí lo leen. La mezcla de esas dos realidades es el bug.
El escenario de ataque no requiere nada exótico. Mandas una request con dos headers que se contradicen: X-Forwarded-For trae la IP real que puso el proxy, y Forwarded: for=127.0.0.1 trae tu versión falsa. Como casi ningún proxy limpia el segundo, este llega completo al backend. Y si el framework prioriza Forwarded por sobre X-Forwarded-For, tu app queda convencida de que el cliente es localhost. Ahí se caen allowlists por IP, rate limits y toda lógica que confíe en la IP del cliente, incluidos los banes de fail2ban o de la app misma si es quien reporta las IPs.
Quién está en la lista (y quién quedó en parche)
Según la investigación, Rails 8.1.3 prioriza el campo for de Forwarded y el fix llegó en Rails 8.1.4.1. Jetty 12.1.7 prioriza los cuatro campos del estándar (for, host, proto, by) sobre los X-Forwarded-*. Spring Boot 4.0.5 también prioriza Forwarded, aunque en 4.2 el default volvió a los X-Forwarded-*. Symfony 8.0.8 exige activar el soporte explícitamente y explota con headers contradictorios, que me parece el diseño honesto: si dos fuentes de verdad chocan, mejor un 400 que una mentira silenciosa. Gin 1.12.0, ASP.NET Core 10 y Werkzeug 3.1.8 ni leen el Forwarded, que también es una respuesta válida.
Y la parte que más me importa, que administro un puñado de servicios autoalojados detrás de nginx: la lista de trusted proxies no te salva sola. Los frameworks solo creen en esos headers si la request viene de un proxy confiable. El problema es que si tu proxy confiable deja pasar el header del cliente intacto, la app no distingue entre lo que escribió el proxy y lo que atravesó el proxy. El proxy confiable queda de mensajero del mentiroso, el clásico confused deputy.
La prueba de una línea
Es un curl de una línea: curl -H 'Forwarded: for=127.0.0.1' contra tu servicio, y comparas lo que la app imprime o deja en el log con la IP real desde donde mandaste la petición. Si gana la falsa, tienes el problema. La solución no es elegante, es aburrida: decidir en un solo lugar cuál header es la fuente de verdad. En mi parque nginx, que no toca el Forwarded, la defensa es escribirlo vacío al pasar el request al backend (proxy_set_header Forwarded "";) y dejar que solo el proxy y la app que tú configuraste coman del mismo plato.
Mi opinión después de leer el estudio y revisar mis configs: los headers de proxy deberían ser parte del contrato explícito de despliegue, no algo que el framework adivina. Que una app lea dos familias de headers distintas y resuelva los conflictos con un criterio interno invisible es apuesta gratis para el atacante. Cachai el punto: no basta con confiar en tu proxy; hay que decirle explícitamente qué no debe cruzar.
Lectura original: Forwarded Without Question: How Web Frameworks Overtrust the Forwarded Header, de Lauri Koskela.
Fuente de inspiración: Forwarded Without Question: How Web Frameworks Overtrust the Forwarded Header