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
- Protecciones que funcionaron
- ① placeholders (valores enlazados) ② validación del charset de la BD
- La mala configuración que quedó
- "confiar en todos los proxies" — sin proxy realmente delante
- Respuesta
- Sanear los encabezados de IP en el límite (corto plazo) → arreglar la config del proxy de confianza (causa de fondo)
Primero, divídelo: ¿"ataque" o "mi propio error"?
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 partir de los síntomas, contrasta cada hipótesis con los 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 descártalas una a una con datos, no con corazonadas; así te equivocarás de causa mucho menos.
Qué fue el ataque: un XFF suplantado que llevaba sondeos de inyección
El bot mete una cadena de sondeo en XFF "en lugar de una IP", con solo una IP de aspecto normal al final. Comprueba una sola cosa:
"¿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 tiene éxito vía XFF. Abusa de "el encabezado que transporta una IP" como un sitio por el que enviar cadenas arbitrarias.
Por qué no hubo daño: dos protecciones
Protección ① valores enlazados (placeholders)
El valor de IP se pasó vía un placeholder como dato, no concatenado en una cadena. La entrada no puede interpretarse como un comando, así que la SQLi no puede formarse por diseño. Es uno de los principales beneficios de usar un framework.
Protección ② 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í no habría ningún problema, 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" → los registros, las sesiones y las comprobaciones de IP reciben el valor falso
✓ 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 un ajuste habitual de la fase de desarrollo que se queda en producción.
Fácil de pasar por alto: la suplantación de XFF no es solo sobre inyección
Cualquier cosa que confíe en la IP del cliente acaba usando el valor falso: 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, 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 (corto plazo, inmediato)
Fija los proxies de confianza correctamente (causa de fondo)
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: tras bloquear el encabezado de IP, el ataque pasó a User-Agent
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 exigía otro enfoque.
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; mover el payload a otros encabezados no almacenados no provoca ningún error. 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: parte del sumidero en lugar de bloquear entradas una a una
Bloquear encabezados de uno en uno no funciona: un atacante siempre puede usar otro encabezado. Razona hacia atrás desde el sumidero (la columna almacenada / la salida) y enumera de golpe cada entrada que llega a él. Como mostró este caso cuando el ataque pasó del encabezado de IP a User-Agent: cuando parchees un encabezado, trata 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: defender justo antes de guardar el valor
Justo tras tratar 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í el error seguía repitiéndose en producción. Este es el punto clave del caso.
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 malicioso volvió como la "IP del cliente" por una ruta que no habíamos saneado. Comprobar en el punto de entrada no puede atraparlo todo.
✗ Defender en la entrada
Sanear los encabezados de la solicitud → la IP maliciosa del cliente vuelve por otra ruta interna → eludido
✓ Defender en la salida
Comprobar justo antes de la escritura en BD → el único sitio por el que pasa cada valor → sin elusión
Así que cambiamos de enfoque 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 le escapa. Enviando el encabezado malicioso por 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 comprobación final va en la salida (el punto de uso)
La regla básica 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 pasar más allá de la comprobación de entrada y volver a aparecer. Validar/escapar justo antes de un sumidero peligroso (BD, HTML, un comando) es el único sitio por el que pasa cada valor, así que ahí va la comprobación final. Haz entrada y salida como dos cosas separadas.
Lección: es más seguro que la entrada maliciosa se rechace con un error visible
Aquí el ataque falló y se hizo visible como una avalancha de notificaciones de error. Eso no es un mal resultado: ser rechazado con un error visible es más seguro que pasar sin que nadie lo note. No descartes las notificaciones de error como ruido; úsalas para detectar 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
- Ejemplo: CVE-2026-102895 GPUStack (una autenticación que confiaba en el origen de la petición dejó que partes sin autenticar actuaran como administrador en una plataforma de IA)
- Glosario: qué es la inyección SQL / qué es el XSS / qué es el SSRF
- Fundamentos: Seguridad básica: .env y claves de API
- Dónde importa: limitación de frecuencia y control de abusos (por qué IP cuentas las peticiones depende de cómo trates XFF)
- El panorama: ¿Next.js y React tienen muchas vulnerabilidades? (lo que se ha encontrado en las funciones de proxy integradas de los frameworks)