Guías de seguridad
Qué es la suplantación de X-Forwarded-For (XFF) — la trampa de la configuración de proxy de confianza
X-Forwarded-For (XFF) es un encabezado que el cliente puede falsificar: los escáneres esconden sondeos de inyección en él y 'confiar en todos los proxies' deja pasar el valor falso. La trampa del proxy de confianza y cómo defenderse.
X-Forwarded-For (XFF) es el encabezado que le dice a tu app "la IP real del visitante es esta" cuando el tráfico pasa por un proxy. El truco: el cliente puede falsificar ese valor libremente. Aquí va un caso común de desarrollo independiente — "nos golpeó un escaneo de suplantación de XFF" — convertido en lección, anonimizado, sin pasos de ataque reproducibles.
- Tipo
- Suplantación de X-Forwarded-For + sondeo de inyección (escaneo automatizado)
- Impacto
- Ninguno (detenido por la defensa en profundidad)
- Cómo salió a la luz
- Las solicitudes de ataque dieron 500; una avalancha de correos de excepción
- Escudos que aguantaron
- ① placeholders (valores enlazados) ② validación del charset de la BD
- El hueco que quedó
- "confiar en todos los proxies" — sin proxy realmente delante
- Respuesta
- Sanear los encabezados de IP en el límite (parche) → arreglar la config del proxy de confianza (raíz)
Primero, divídelo: ¿"ataque" o "mi propio error"?
La regla es: no saltes a "¡ataque!" al ver errores. Si acabas de cambiar la configuración, sopesa "quizá mi cambio causó esto" con igual seriedad.
Sospecha de tu propio cambio
Deja que el valor sea la prueba
¿Dirigido o un escaneo?
La visión de este sitio: al razonar hacia atrás desde los síntomas, mata las hipótesis con datos
"Lo que acabo de tocar parece sospechoso" es un buen punto de partida. Pero si la prueba (aquí, el propio valor) lo refuta, déjalo ir. Forma varias hipótesis y derríbalas una a una con datos, no con corazonadas — el diagnóstico erróneo cae en picado.
Qué fue el ataque: sondeos de inyección montados en un XFF suplantado
El bot empaqueta una cadena de sondeo en XFF "en lugar de una IP", con solo una IP de aspecto normal al final. El objetivo es uno solo —
"¿Esta app confía en el valor XFF y lo mezcla en bruto en una consulta de BD, o lo imprime directo en el HTML?"
Si la app es descuidada, la inyección SQL o el XSS aterriza vía XFF. Abusa de "el sitio que transporta una IP" como un punto de inyección de cadenas arbitrarias.
Por qué el impacto fue cero: dos escudos
Escudo ① valores enlazados (placeholders)
El valor de IP se pasó vía un placeholder como dato, no concatenado en una cadena. La entrada no puede cruzar a "comando", así que la SQLi no puede formarse por diseño — uno de los mayores beneficios de usar un framework.
Escudo ② validación del charset de la BD
Los bytes malformados (UTF-8 sobrelargo, etc.) son secuencias inválidas que el charset de la columna no puede almacenar. La BD rechazó la escritura y lanzó un error — el ataque falló y fue detectado.
Todo lo que ocurrió: las propias solicitudes del atacante dieron 500 y salió una avalancha de correos de excepción. Sin fuga, sin manipulación, sin inicio de sesión no autorizado. Un caso de manual de la defensa en profundidad funcionando como estaba previsto.
El problema real: confiar en todos los proxies
Si parásemos aquí sería una historia feliz — pero hay algo que no se puede ignorar. ¿Por qué llegó siquiera el XFF suplantado al manejo de IP?
✗ Confiar en todos los proxies (comodín)
XFF falso del cliente → adoptado como la "IP real" → registros, sesiones, comprobaciones de IP envenenados
✓ No confiar en nada (por defecto)
XFF falso ignorado → la IP real del socket se usa → la suplantación no tiene efecto
Este montaje no tenía proxy inverso ni CDN — el servidor web estaba directamente expuesto. Así que el XFF nunca necesitó confianza, y sin embargo estaba puesto en "confiar en todos". "Permitirlo todo sin más" es el atajo clásico de la fase de desarrollo que sobrevive hasta producción.
Punto ciego: la suplantación de XFF no es solo sobre inyección
Cualquier cosa que confíe en la IP del cliente queda envenenada: elusión del límite de tasa, elusión de la lista de IP permitidas, evasión de baneos, registros de auditoría falsificados. Incluso con defensas de inyección sólidas como rocas, esto es un problema aparte. Inventaria "¿dónde confío en la IP?" y comprueba la base de esa confianza (= tu configuración de proxy).
Solución: sanear en el límite → fijar la confianza correctamente
Sanea los encabezados de IP en el límite (parche, instantáneo)
Fija los proxies de confianza correctamente (raíz)
http/https, así que los cambios pueden causar bucles de redirección — verifica primero en un entorno.No confundas 'IP válida' con 'IP real'
Verifica: tras arreglarlo, repite el ataque para confirmar
Convierte "creo que lo arreglé" en "se detuvo con seguridad". ① aplica solo a un entorno → ② envíate tú mismo el mismo encabezado malformado → ③ confirma que los recuentos de errores no suben → ④ confirma que el acceso normal sigue funcionando → ⑤ despliega en todas partes. El despliegue por etapas previene accidentes.
Seguimiento: bloquea el encabezado de IP, te golpean en User-Agent (la trampa del juego del topo)
Decenas de minutos tras añadir el saneamiento, volvió el mismo tipo de error — en una columna distinta esta vez: user_agent, no ip_address. Bloqueado en el encabezado de IP, el atacante movió el mismo payload al encabezado User-Agent. Eso obliga a reencuadrar.
La pregunta correcta no es "¿qué encabezado es peligroso?" sino "¿a qué columna de BD aterriza en última instancia la entrada no confiable?" Si los únicos sumideros son ip_address y user_agent, sanear las entradas que los alimentan (encabezados de IP + User-Agent, más Referer por si acaso) detiene por diseño los errores derivados de las sesiones — otros encabezados no almacenados no pueden pivotarse. Los User-Agent legítimos son ASCII/UTF-8 válido, así que quitar solo los bytes inválidos no tiene impacto.
La visión de este sitio: razona hacia atrás desde el sumidero (no selles entradas una a una)
Sellar encabezados de uno en uno es un juego del topo — las entradas que un atacante puede usar son infinitas. Razona hacia atrás desde el sumidero (la columna almacenada / la salida) y enumera de golpe cada entrada que llega a él. Como nos enseñó "bloquea la IP, te golpean en User-Agent": cuando parchees un encabezado, sella al mismo tiempo las otras entradas que llegan al mismo sumidero. Y de nuevo — repetimos el segundo ataque y confirmamos que se detuvo antes de desplegar.
Y otra vez: el filtro de entrada fue eludido — defiende en la salida
Justo tras sellar también User-Agent, volvió el mismo error de la columna ip_address. El saneamiento de entrada estaba desplegado, y repetir el mismo encabezado malformado en una autoprueba no lo reproducía — y aun así producción seguía recurriendo. Este es el quid.
La razón: la lógica del framework para "resolver la IP del cliente" es internamente compleja — múltiples notaciones de encabezado (X-Forwarded-For, Forwarded, encabezados personalizados), combinaciones con los ajustes de confianza del proxy, orden de análisis y caché. En algún punto de esas rutas internas, un valor contaminado resurgió como la "IP del cliente" por una ruta que no habíamos saneado. El punto de entrada no puede atraparlo todo.
✗ Defender en la entrada
Sanear los encabezados de la solicitud → la IP del cliente resurge por otra ruta interna → eludido
✓ Defender en la salida
Comprobar justo antes de la escritura en BD → el único punto de estrangulamiento por el que todo pasa → sin elusión
Así que le dimos la vuelta y defendimos en la salida (justo antes de la escritura en BD). Subclasifica la clase de almacenamiento de sesiones y sobrescribe solo las partes que producen el valor (concepto): la IP almacenada se vuelve null salvo que sea una IP válida; al User-Agent almacenado se le quitan los bytes inválidos y se le limita la longitud. Por mucho que se comporte internamente la resolución de la IP del cliente, la comprobación siempre corre justo antes de la escritura, así que nada se cuela. Martilleando ambas rutas de ataque decenas de veces cada una, los recuentos de errores no subieron y las sesiones legítimas siguieron funcionando.
La visión de este sitio: la última línea de defensa es la salida (el punto de uso)
La máxima de seguridad es "no confíes en la entrada; valida/escapa siempre en el punto de uso (la salida)". Las comprobaciones de entrada ayudan a rechazar pronto, pero la información que el framework resuelve/deriva internamente (como la IP del cliente) puede colarse más allá de la entrada y resurgir. Validar/escapar justo antes de un sumidero peligroso — BD, HTML, un comando — es el punto de estrangulamiento por el que todo pasa, y esa es la última línea de defensa. Haz entrada y salida como dos cosas separadas.
La mayor lección: ser rechazado ruidosamente es más seguro
Aquí el ataque falló y se hizo visible como una avalancha de notificaciones de error. Irónicamente, eso no es malo: ser rechazado ruidosamente vence a ser dejado pasar en silencio. No descartes las notificaciones de error como "ruido" — úsalas como un sensor de anomalías. No te quedes en "bloqueamos el impacto"; arregla "por qué llegó a las partes internas". Esa es la postura de este sitio.
Sigue leyendo
- Glosario: qué es la inyección SQL / qué es el XSS / qué es el SSRF
- Fundamentos: Seguridad básica: .env y claves de API
FAQ
Q¿Puedo confiar en X-Forwarded-For?
No tal cual. XFF es un encabezado que el cliente puede fijar libremente — un atacante puede falsificar una IP con aspecto real o una cadena de sondeo de inyección. El único XFF fiable es el que añade un proxy que TÚ colocaste delante y en el que confías explícitamente.
Q¿Y si no uso un proxy inverso ni una CDN?
No confíes en ningún XFF en absoluto (deja el valor por defecto del framework). Si no hay proxy delante pero 'confías en todos los proxies', cualquiera puede alimentar a tu app con una IP falsa. Sin proxy en la ruta → confiar en nada es el ajuste correcto.
Q¿Basta con sanear el encabezado de IP?
Detiene los sondeos de inyección y la avalancha de errores, pero eso es higiene (frenar la hemorragia). Una 'IP con aspecto válido' no es una 'IP real'. La confianza en la IP real del cliente debe venir de la configuración del proxy de confianza, no del saneamiento.
QArreglé el encabezado de IP pero el mismo error volvió. ¿Por qué?
El mismo payload se movió a otro punto de entrada (p. ej. el encabezado User-Agent) y llegó a la misma columna almacenada. Parchear encabezados uno a uno es un juego del topo. Razona hacia atrás desde '¿a qué columna de BD / salida llega en última instancia la entrada no confiable?' y sanea juntas todas las entradas que la alimentan (encabezados de IP + User-Agent, etc.).