··
Tras meter Cloudflare delante, descubrí que el rate-limit de varios proyectos había dejado de funcionar y que en uno de ellos la verificación de propiedad de recursos se había roto sin que nadie se diera cuenta. La causa, una sola línea de código que confiaba en x-real-ip cuando ya no debía. Cuento el patrón correcto para getClientIp en Node.

Última entrega de la serie. Después de migrar el VPS a Cloudflare con DNS-01 y AOP, firewall por IPs y monitor del token, abrí los logs de varios proyectos para revisar que todo respondía correcto y vi algo raro. El rate limit de mis APIs había dejado de funcionar como antes. Una IP podía hacer cientos de peticiones sin que el limitador la cortara. Y en otro proyecto, los hashes de IP que servían para verificar la propiedad de recursos anónimos eran exactamente iguales para todos los visitantes en una ventana de tiempo.
El bug es uno solo y se manifiesta en cualquier proyecto Node.js que despliegues detrás de la cadena Cloudflare → Traefik → app y que tenga lógica que dependa de la IP del cliente. La explicación lleva un par de párrafos pero el fix son cuatro líneas. Lo importante es entender por qué pasa, porque seguramente lo tienes en tus apps y no lo sabes.
El visitante hace una petición desde su IP 1.2.3.4. El edge de Cloudflare la recibe, la procesa, y la reenvía a tu origen añadiendo varias cabeceras.
cf-connecting-ip: 1.2.3.4
x-forwarded-for: 1.2.3.4
x-real-ip: 1.2.3.4 (si CF lo añade, depende de config)Hasta aquí todo bien. Esa petición la recibe Traefik en el VPS. Y aquí pasa una cosa que es correcta de seguridad pero contra-intuitiva si no la conoces.
Para que x-forwarded-for sea fiable y no spoofeable desde el cliente, configuré los forwardedHeaders.trustedIPs de Traefik con los rangos oficiales de Cloudflare. Esto le dice a Traefik cualquier petición que venga de estas IPs es de un proxy de confianza, respeta sus cabeceras XFF. Y Traefik hace lo correcto, preserva el XFF que vino, y además añade al final la IP TCP del peer inmediato, que es la del edge de Cloudflare, no la del cliente. También sobrescribe x-real-ip con la IP del peer inmediato.
Resultado, lo que llega a tu app después de Traefik:
cf-connecting-ip: 1.2.3.4 (intacta)
x-forwarded-for: 1.2.3.4, 162.158.x.x (cliente real, edge CF al final)
x-real-ip: 162.158.x.x (edge CF, sobrescrito por Traefik)Si tu código lee x-real-ip, lee la IP del edge de CF.
Si tu código lee el rightmost de x-forwarded-for (porque te enseñaron que el último elemento es la IP "más fiable"), lee la IP del edge de CF.
Si tu código lee el leftmost de x-forwarded-for, lee la IP real, pero esa estrategia es vulnerable a spoofing si el cliente hostil mete su propio XFF antes de tocar tu proxy.
La única cabecera 100% fiable y sin ambigüedad es cf-connecting-ip, porque la pone CF en su edge y no se puede spoofear si tienes AOP activo (cualquier conexión TLS al origen viene firmada por CF, no hay bypass directo posible).
Cualquier código que dependa de la IP real del cliente queda roto.
El edge de Cloudflare tiene unas pocas IPs públicas (decenas en total) que reenvían tráfico de millones de visitantes. Sí, en el plan Free se puede dejar una Rate Limit Rule en el propio edge de Cloudflare, pero no sustituye al rate limit del origen, lo complementa. Si tu rate limit cuenta peticiones por IP y tu IP es "la del edge CF", todos los visitantes comparten el mismo bucket. Resultado, el limitador trata a 5000 visitantes legítimos como una sola entidad y los corta a todos juntos, o no corta a un atacante porque el tráfico legítimo "no llena el bucket".
ip_hashEste es el caso más serio que vi. En PasteBin tengo un sistema en el que los pastes anónimos no requieren cuenta, pero el creador puede editar o borrar lo que él mismo ha creado. La identificación se hace con un hash de la IP del visitante. Si todos los visitantes anónimos tienen la misma IP (la del edge CF), todos comparten el mismo hash y cualquier guest puede editar o borrar pastes de cualquier otro guest. Sin fix, esto era un bug de seguridad real.
En Time Capsule tengo logs auditables firmados con HMAC, donde la IP forma parte del input del HMAC. Sin fix, todos los logs de la franja en cuestión llevan exactamente el mismo hash, lo que rompe la garantía de que el log identifica unívocamente al actor. Auditoría inservible.
Cualquier app que mantenga su propia lista de IPs baneadas pierde la capacidad de banear individuos, porque solo ve unas pocas IPs de CF. O bloquea a CF entera (catastrófico) o no bloquea a nadie.
getClientIpEl fix es siempre el mismo, una función que prioriza cf-connecting-ip y solo cae a x-real-ip o XFF si la primera no está. Algo así.
/**
* Obtiene la IP del cliente real cuando la app corre detras de
* Cloudflare + Traefik. Prioriza cf-connecting-ip porque es la unica
* cabecera no spoofeable mientras AOP este activo en el origen.
*/
export function getClientIp(req: { headers: Record<string, string | string[] | undefined> }): string {
// 1. Cloudflare Connecting IP. La pone CF en su edge.
// Con AOP activo en el origen, no es spoofeable via bypass directo al VPS.
const cf = headerValue(req, "cf-connecting-ip");
if (cf) return cf;
// 2. Fallback para entornos sin CF delante (dev local, staging sin CF, etc.).
const real = headerValue(req, "x-real-ip");
if (real) return real;
// 3. Ultimo recurso, X-Forwarded-For rightmost. Traefik mete la IP TCP del
// peer al final cuando la IP origen no esta en trustedIPs.
const xff = headerValue(req, "x-forwarded-for");
if (xff) {
const parts = xff.split(",").map(s => s.trim()).filter(Boolean);
if (parts.length) return parts[parts.length - 1];
}
return "unknown"; // o "0.0.0.0" si tu logica requiere una IP valida para hashear
}
function headerValue(req: any, name: string): string | undefined {
const v = req.headers[name];
if (Array.isArray(v)) return v[0]?.trim() || undefined;
return typeof v === "string" ? v.trim() : undefined;
}Tres apuntes importantes sobre por qué se prioriza así.
cf-connecting-ip primero, porque es la única cabecera que el cliente no puede introducir él mismo cuando AOP está activo. Sin AOP, en teoría alguien con la IP del VPS podría enviar una petición directa con cf-connecting-ip falseado. Con AOP, ese alguien no puede ni completar el handshake TLS.
x-real-ip segundo, como red para entornos sin CF (desarrollo local, otro stack de proxy).
XFF rightmost al final, porque cuando Traefik no tiene a la IP origen en trustedIPs sí escribe la IP TCP en el último slot del XFF, así que el rightmost es lo más cercano a la verdad en ausencia de las otras dos.
Esta función es de las que vale la pena cubrir bien. Si la lías, no se rompe ruidosamente, se rompe en silencio. Cuatro tests mínimos.
describe("getClientIp", () => {
it("prioriza cf-connecting-ip sobre todo", () => {
expect(getClientIp({ headers: {
"cf-connecting-ip": "1.2.3.4",
"x-real-ip": "5.6.7.8",
"x-forwarded-for": "9.10.11.12"
}})).toBe("1.2.3.4");
});
it("cae a x-real-ip si no hay cf-connecting-ip", () => {
expect(getClientIp({ headers: {
"x-real-ip": "5.6.7.8",
"x-forwarded-for": "9.10.11.12"
}})).toBe("5.6.7.8");
});
it("cae a XFF rightmost si no hay nada mas", () => {
expect(getClientIp({ headers: {
"x-forwarded-for": "1.2.3.4, 5.6.7.8, 9.10.11.12"
}})).toBe("9.10.11.12");
});
it("devuelve fallback si no hay ningun header", () => {
expect(getClientIp({ headers: {} })).toBe("unknown");
});
});Tras la migración audité los proyectos propios y apliqué el fix donde hacía falta.
MyBox, en request-utils.ts. Usado por rate limit y logs.
Time Capsule, en rate-limit.ts. Crítico, el HMAC de los logs de auditoría firmaba con la IP edge CF.
PasteBin, en security.ts. Aún más crítico, el ip_hash identificaba propietarios y sin fix todos los anónimos compartían identidad.
JMO Labs, parche aplicado en sesión anterior porque el comportamiento ya se notaba.
Proyectos que ya lo tenían bien por diseño previo (y que confirmé sin tocar).
ScamDetector, ya priorizaba cf-connecting-ip de antes.
Proyectos donde no aplica porque no hay lógica server-side dependiente de IP.
CV Portfolio, Calculator, Ofusca, todos solo-frontend.
n8n, Infisical, ntfy, software de terceros con sus propias opciones de proxy. Algunos requieren config específica (NTFY_BEHIND_PROXY=true, etc.). Si detectas rate limit prematuro en producción, ahí es donde mirar.
Tres comprobaciones rápidas.
Endpoint debug temporal que loguea las cabeceras relevantes. Hacer una petición desde una IP conocida y ver qué llega.
app.get("/_debug/ip", (req, res) => {
res.json({
cf: req.headers["cf-connecting-ip"],
real: req.headers["x-real-ip"],
xff: req.headers["x-forwarded-for"],
reqIp: req.ip, // Express con trust proxy
socket: req.socket.remoteAddress,
});
});Si cf es tu IP real y real es una IP del rango de Cloudflare (suelen empezar por 162.158, 104.16-31, 172.64-71), tienes el problema y necesitas el fix.
Si tu rate limit está configurado a, digamos, 60 peticiones por minuto y nunca corta a nadie aunque el tráfico sea alto, sospecha. Tira un ab o equivalente desde una IP, fuera de trusted_ips, contra un endpoint, y mira si la petición 61 da 429. Si no, es muy probable que el contador esté agregando todo el tráfico bajo una sola IP virtual.
Grep por nombres comunes, x-real-ip, req.ip, getClientIp, getClientIP, getIp, real_ip, remote_addr. En cada uno revisa si lo primero que se mira es cf-connecting-ip o no.
cf-connecting-ip es de fiarLa objeción razonable a confiar en una cabecera HTTP es cualquiera la puede mandar. Y es cierto, fuera de un proxy controlado, una cabecera HTTP la puede falsear cualquier cliente.
Lo que hace fiable a cf-connecting-ip en mi setup es la combinación con AOP. Para que una petición llegue a mi app, primero tiene que pasar el handshake TLS de Traefik. Y el handshake exige cert cliente firmado por la CA de Cloudflare. Solo Cloudflare presenta ese cert. Si alguien intenta saltarse CF y conectar directo al VPS, el handshake falla. Por tanto, si una petición llega a mi app, viene por CF, y por tanto la cabecera cf-connecting-ip la puso CF (porque CF la pone siempre y sobrescribe la del cliente si la mandó). El cliente no puede llegar a tu app sin pasar por CF, y CF normaliza la cabecera.
Sin AOP la garantía es más débil pero aún razonable, porque sigue siendo necesario pasar por CF para evitar el handshake genérico de Traefik. Con AOP es matemáticamente fiable.
Con esta entrega cierro la serie. Lo que empezó como "voy a meter Cloudflare delante para esconder la IP del VPS" se convirtió en seis ramificaciones técnicas, una por capa de defensa o por trampa que descubrí en el camino. El stack final es razonablemente robusto sin pagar Pro, no me ata a CF más allá de un par de horas de trabajo si decido salir, y me deja una infraestructura donde el origen literalmente no escucha a nadie que no sea Cloudflare.
El detalle del cf-connecting-ip es el que recomiendo encarecidamente revisar si tienes proyectos propios detrás de Cloudflare. Tres líneas de código bien priorizadas hacen funcionar de nuevo cualquier rate limit, cualquier owner verification por hash y cualquier auditoría de logs. Tres líneas mal puestas pueden tener un agujero abierto durante meses sin que nadie se entere.
Después de cerrar esta serie original, dediqué una entrega adicional al panel admin del VPS, con Cloudflare Tunnel y Access para sacar Dokploy, Infisical y Umami del DNS público. Mismo enfoque, una capa más de defensa antes del primer byte servido.

Jose, autor del blog
QA Engineer. Escribo en voz alta sobre automatización, IA y arquitectura de software. Si algo te ha servido, escríbeme y cuéntamelo.
¿Qué te ha parecido? ¿Qué añadirías? Cada comentario afina la siguiente entrada.
Si esto te ha gustado

ScamDetector combina inteligencia artificial, búsqueda de reputación de teléfonos y escaneo de URLs para ayudarte a identificar estafas digitales. Sin registro, sin datos almacenados.

Repaso completo de las medidas de seguridad que puedes aplicar a un VPS Linux: desde CrowdSec y el firewall hasta el hardening del kernel, pasando por SSH, Docker y las actualizaciones automáticas.

Nuestros posts viven en una base de datos SQLite. Si alguien accede a ella, puede cambiar cualquier artículo sin dejar rastro. Construimos un verificador externo con hashes SHA-256 y firma Ed25519 que vigila la integridad desde un segundo servidor.