Pekke deja al usuario pegar la URL de un producto de cualquier tienda: el servidor la descarga, la parsea y saca nombre, precio e imagen. Es lo que hace que la plataforma sea multi-tienda en lugar de un catálogo cerrado, y es también la funcionalidad más peligrosa del producto.
Un endpoint que descarga una URL que dicta un desconocido es la definición de un SSRF (server-side request forgery). El atacante no ataca tu servidor: lo usa como proxy. Le pide que se conecte a sitios a los que él no llega y le devuelva lo que vea. Desde fuera, https://pekke.es/api/extract parece un lector de fichas de producto. Desde dentro, es un cliente HTTP con salida a la red privada.
Lo que sigue es cómo está resuelto, con el código que está en producción, y —más importante— qué es lo que no resuelve cada capa. La mayoría de los tutoriales se paran en la primera.
¿Qué puede pedir un atacante a un scraper sin protección?
Tres cosas, por orden de gravedad:
- Escanear la red interna.
http://10.0.3.14:8080responde o no responde, y el tiempo que tarda ya es información. Con un par de cientos de peticiones tienes el mapa. - Leer servicios internos sin autenticación. Bases de datos con panel HTTP, colas, paneles de administración que confían en que solo se llega desde dentro.
- Robar credenciales del proveedor de nube. Esto es lo caro. En AWS y Azure,
http://169.254.169.254/latest/meta-data/iam/security-credentials/devuelve credenciales temporales del rol de la instancia. En GCP lo mismo desdemetadata.google.internal. No hace falta exfiltrar nada raro: el propio scraper te devuelve el cuerpo de la respuesta dentro de un campo del JSON.
El tercero es el que convierte un fallo de validación en un incidente con nombre propio. Y el vector es literalmente pegar una URL en un formulario.
Nivel 1: la lista negra de hostnames, y por qué no sirve
La primera reacción de todo el mundo es mirar el nombre de dominio:
// NO uses esto como única defensa
const BLOQUEADOS = ['localhost', '127.0.0.1', '169.254.169.254'];
if (BLOQUEADOS.includes(new URL(url).hostname)) reject();
Falla por dos sitios a la vez.
El primero es la representación. 127.0.0.1 no es la única forma de escribir esa dirección. 0177.0.0.1 es octal y apunta al mismo sitio. 0x7f000001 es hexadecimal. 2130706433 es el entero de 32 bits. [::ffff:127.0.0.1] es la forma IPv4-mapped en IPv6. 0.0.0.0 resuelve a la interfaz local en buena parte de los stacks. Ninguna está en tu lista.
El segundo, y este no se arregla añadiendo entradas, es que el atacante no necesita escribir una IP. Puede registrar tienda-legitima.example, apuntarla a 127.0.0.1 en su propio DNS y pasarte esa. El hostname es impecable. La resolución es lo que no lo es.
Nivel 2: validar la IP resuelta, no el nombre
La conclusión es que la comprobación no puede ser sobre el texto que te llega, sino sobre la dirección a la que ese texto lleva. Es decir: resuelve tú el DNS y valida el resultado.
Estos son los rangos que hay que rechazar. La lista completa importa más de lo que parece, porque cada hueco es una ruta hacia dentro:
// Rangos de IPs privadas, loopback y metadatos de cloud que nunca deben ser accesibles
// desde este endpoint para prevenir SSRF (incluyendo IPv4-mapped en IPv6 y notación no estándar).
const PRIVATE_IP_PATTERNS: RegExp[] = [
/^127\./, // loopback IPv4
/^10\./, // RFC1918 clase A
/^172\.(1[6-9]|2\d|3[01])\./, // RFC1918 clase B
/^192\.168\./, // RFC1918 clase C
/^169\.254\./, // link-local / AWS metadata (169.254.169.254)
/^0\./, // red "esta" (0.0.0.0/8)
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./, // CGNAT (RFC6598)
/^::1$/, // IPv6 loopback
/^fc00:/i, // IPv6 unique local
/^fe80:/i, // IPv6 link-local
/^::ffff:(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/i, // IPv4-mapped IPv6
/^0+\./, // notación octal (0177.0.0.1 = 127.0.0.1)
/^0x/i, // notación hexadecimal (0x7f000001 = 127.0.0.1)
];
const BLOCKED_HOSTS = new Set([
'localhost',
'169.254.169.254', // AWS/Azure IMDS
'metadata.google.internal', // GCP metadata
'[::1]', // IPv6 loopback con brackets
]);
function isPrivateIpOrHost(value: string): boolean {
const v = value.toLowerCase().trim();
if (BLOCKED_HOSTS.has(v)) return true;
return PRIVATE_IP_PATTERNS.some(r => r.test(v));
}
Tres detalles que se olvidan con frecuencia:
- CGNAT (
100.64.0.0/10). No es RFC1918, así que no aparece en las listas copiadas de Stack Overflow. Es el rango que usan muchos proveedores para la red entre nodos. - IPv6. Si tu runtime resuelve AAAA y tú solo validas IPv4, has dejado la puerta de al lado abierta.
fc00::/7yfe80::/10son el equivalente a RFC1918 y link-local. - IPv4-mapped.
::ffff:169.254.169.254es el endpoint de metadatos escrito en IPv6. Pasa cualquier filtro que solo mire patrones IPv4.
Y ahora la parte que hace el trabajo: resolver antes de pedir.
/** Resuelve el hostname via DNS y comprueba que ninguna IP resuelta sea privada.
* Esto mitiga el ataque de DNS rebinding: el check se hace DESPUÉS de la resolución,
* no antes, eliminando la ventana de tiempo entre check y fetch.
*/
async function isHostnameSafe(hostname: string): Promise<boolean> {
// Primero check estático del hostname (bloquea nombres conocidos y IPs literales)
if (isPrivateIpOrHost(hostname)) return false;
try {
const { promises: dnsPromises } = await import('node:dns');
const [v4, v6] = await Promise.allSettled([
dnsPromises.resolve4(hostname),
dnsPromises.resolve6(hostname),
]);
const addrs: string[] = [];
if (v4.status === 'fulfilled') addrs.push(...v4.value);
if (v6.status === 'fulfilled') addrs.push(...v6.value);
// Si no se resuelve nada, rechazar (hostname inválido o privado)
if (addrs.length === 0) return false;
// Todas las IPs resueltas deben ser públicas
return addrs.every(ip => !isPrivateIpOrHost(ip));
} catch {
// Si dns no está disponible (edge runtime), caer al check estático solamente
return true;
}
}
Fíjate en addrs.every(...). No vale con que alguna IP sea pública: un nombre puede resolver a varias direcciones y el cliente HTTP elige la que quiera. Si una sola es privada, el nombre entero se rechaza.
El catch es la línea que decide
Ese último bloque merece un párrafo aparte porque es donde se decide si la protección vale algo. En el código de arriba, si node:dns no está disponible —el runtime edge de Vercel, por ejemplo, no lo expone— la función devuelve true y la petición sigue adelante con el check estático solamente. Es una decisión de compatibilidad: el mismo fichero corre en dos runtimes y en uno no hay resolutor.
Es una decisión defendible solo si sabes que no vas a desplegar en ese runtime. Si tu endpoint corre en Node y punto, esa rama debe devolver false. Una comprobación de seguridad que no se puede ejecutar tiene una única respuesta correcta, y es «no».
} catch {
// Sin resolutor no hay comprobación posible, y sin comprobación no se pasa.
return false;
}
Nivel 3: la redirección, el agujero que lo reabre todo
Aquí es donde se cae casi todo el código que he visto, incluido código que hace bien todo lo anterior.
Validas el hostname. Resuelves el DNS. Compruebas las IP. Todo correcto. Y después llamas a fetch(url), que sigue las redirecciones por su cuenta. La tienda responde 302 Location: http://169.254.169.254/latest/meta-data/, fetch obedece, y tu validación se ha quedado mirando el primer hostname mientras la petición acababa donde quiso el atacante.
La solución de una línea es redirect: 'error'. Funciona, y en Pekke fue lo primero que hubo. El problema es que rompe un caso de uso real: los enlaces cortos de Amazon (amzn.eu/d/…) son exactamente lo que copia la app del móvil, y son una redirección. Con redirect: 'error' el scraper fallaba, el usuario rellenaba la ficha a mano y el enlace corto se guardaba crudo, sin resolver.
Así que las redirecciones se siguen, pero a mano y pasando cada destino por el mismo control que el original:
const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
// Saltos máximos de una cadena de redirecciones. Un corto de Amazon gasta uno
// o dos; más allá de esto es un bucle o una tienda que se está haciendo la
// interesante, y el timeout global se lo comería igual.
const MAX_REDIRECTS = 4;
async function fetchFollowingRedirects(
startUrl: URL,
signal: AbortSignal,
): Promise<{ res: Response; finalUrl: URL }> {
let current = startUrl;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const res = await fetch(current.toString(), {
headers: { ...BROWSER_HEADERS, Host: current.hostname },
signal,
redirect: 'manual',
});
const location = res.headers.get('location');
if (!REDIRECT_STATUS.has(res.status) || !location) {
return { res, finalUrl: current };
}
let next: URL;
try {
next = new URL(location, current); // `Location` puede venir relativo
} catch {
throw new RedirectError('bad_redirect', 'La tienda redirige a una dirección inválida');
}
if (!['http:', 'https:'].includes(next.protocol)) {
throw new RedirectError('bad_redirect', 'La tienda redirige a una dirección inválida');
}
if (!(await isHostnameSafe(next.hostname))) {
throw new RedirectError('blocked_host', 'URL no permitida o inaccesible');
}
current = next;
}
throw new RedirectError('too_many_redirects', 'La URL encadena demasiadas redirecciones');
}
Cuatro cosas que hace este bucle y que conviene no perder al copiarlo:
redirect: 'manual', no'follow'. Es lo que devuelve el control del salto a tu código.new URL(location, current). La cabeceraLocationpuede ser relativa (/producto/123). Resolverla contra la URL actual es obligatorio; concatenar strings, no.- Filtrar el esquema. Sin esto,
Location: file:///etc/passwdogopher://…entran por la puerta que acabas de vigilar. MAX_REDIRECTS. Sin tope, dos servidores que se redirigen mutuamente te tienen ocupado hasta el timeout.
El timeout, por cierto, va sobre la cadena entera y no por salto. Un AbortController creado fuera del bucle y compartido por todas las peticiones:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 12_000);
try {
({ res, finalUrl } = await fetchFollowingRedirects(parsedUrl, controller.signal));
} finally {
clearTimeout(timer);
}
Si el timeout fuera por salto, cuatro redirecciones a 12 s dan 48 s de función ocupada por petición. Eso ya no es un problema de seguridad, es un problema de factura.
Lo que este diseño todavía no cierra
Y aquí está la parte que no suele contarse, porque estropea el final feliz.
Resolver el DNS antes de la petición estrecha muchísimo la ventana del rebinding, pero no la cierra del todo. La razón es que fetch() no sabe nada de tu resolución: cuando le pasas current.toString(), vuelve a resolver el nombre por su cuenta. Son dos resoluciones distintas separadas por unos milisegundos, y un DNS con TTL de 0 controlado por el atacante puede devolver una IP pública en la primera y una privada en la segunda.
En la práctica es un ataque incómodo —hay que ganar una carrera de milisegundos y no siempre hay caché que ayude— pero «incómodo» no es «imposible». La forma de cerrarlo de verdad es que la dirección que validas sea exactamente la que recibe el socket, y eso se hace interviniendo en el lookup del cliente HTTP:
import { Agent } from 'undici';
import { promises as dns } from 'node:dns';
/**
* La validación ocurre dentro del propio `lookup`, así que la IP comprobada es
* literalmente la que se le entrega al socket. No hay segunda resolución y por
* tanto no hay ventana que ganar.
*
* El `Host` y el SNI los sigue poniendo undici a partir del hostname de la URL,
* de modo que el virtual hosting y el certificado TLS siguen funcionando.
*/
const safeAgent = new Agent({
connect: {
lookup(hostname, options, callback) {
dns.lookup(hostname, { all: true, verbatim: true })
.then((addresses) => {
const publicas = addresses.filter((a) => !isPrivateIpOrHost(a.address));
if (publicas.length === 0) {
callback(new Error(`Host no permitido: ${hostname}`), '', 0);
return;
}
if (options.all) callback(null, publicas);
else callback(null, publicas[0].address, publicas[0].family);
})
.catch((err) => callback(err, '', 0));
},
},
});
const res = await fetch(url, { dispatcher: safeAgent, redirect: 'manual' });
Requiere runtime de Node con undici —no funciona en un runtime edge— y sigue necesitando el bucle de redirecciones de antes, porque el lookup protege la conexión pero no decide a qué URL vas. Es la capa de arriba, no la sustituta.
Hay una segunda cosa abierta que no es de SSRF pero vive en el mismo endpoint: await res.text() sobre un cuerpo sin límite. Una tienda hostil que responda un flujo de gigabytes te llena la memoria de la función antes de que el timeout la corte. Se arregla leyendo el cuerpo por trozos y abortando al pasar de un tamaño razonable —para una ficha de producto, 2 MB sobran—, y comprobando content-type antes de parsear.
Checklist
Si estás construyendo algo parecido, esto es lo mínimo:
- Aceptar solo
http:yhttps:en la URL de entrada, y también en cadaLocation. - Resolver el hostname a IP y validar todas las direcciones devueltas, A y AAAA.
- Bloquear loopback, RFC1918, link-local (
169.254.0.0/16), CGNAT (100.64.0.0/10),0.0.0.0/8, y sus equivalentes IPv6 y IPv4-mapped. - Bloquear por nombre
localhostymetadata.google.internal. - Rechazar cuando la resolución falle. Fallar cerrado.
- Seguir las redirecciones a mano, revalidando cada salto, con tope de saltos.
- Un solo timeout para toda la cadena.
- Fijar la conexión a la IP validada si el runtime lo permite.
- Limitar el tamaño del cuerpo que descargas.
- Limitar por IP el número de peticiones al endpoint.
- No devolver al cliente el cuerpo crudo de la respuesta: solo los campos que has extraído.
El último punto es una defensa en profundidad barata y que casi nadie aplica. Aunque toda la validación fallara, un atacante que solo recibe { name, price, image_url } tiene mucho menos que rascar que uno que recibe el HTML entero.
Este endpoint está en producción en Pekke, la plataforma de listas de nacimiento de Giflow Lab: los padres pegan la URL de un regalo de cualquier tienda y la ficha se rellena sola. El resto de decisiones de arquitectura —reservas sin cuenta, RLS, frontera SSR— están en el caso de estudio de Pekke.