Ir al contenido
CodeAuditAgent
Todos los artículos

Defensa contra SSRF en webhooks, vistas previas de enlaces y descargadores de URL

Cómo el SSRF convierte webhooks, vistas previas e importadores en una vía hacia los metadatos de la nube y los servicios internos, y las defensas que resisten.

· 7 min de lectura · Lina Source LLC

Cualquier funcionalidad que reciba una URL de un usuario y la descargue desde tu servidor es un posible server-side request forgery (SSRF, CWE-918). Los webhooks, las vistas previas de enlaces, el avatar desde una URL, las importaciones de RSS y de calendarios, los renderizadores de PDF y los botones de «importar desde una URL» comparten todos la misma forma: tu servidor hace una petición a un destino elegido por otra persona.

El problema es dónde está tu servidor. Puede alcanzar cosas que el atacante no: el servicio de metadatos de la nube, paneles de administración internos, bases de datos sin contraseña en una red privada y servicios en localhost. El SSRF convierte tu servidor en el proxy del atacante hacia esa red. Es especialmente fácil de introducir en equipos pequeños, porque la funcionalidad que lo causa parece inofensiva: un campo de URL en una página de ajustes, una tarjeta de vista previa en un chat, un helper que descarga una foto de perfil. Nada de esto parece código de seguridad de red, así que rara vez se revisa como tal.

Qué busca un atacante

  • Endpoints de metadatos de la nube, el más famoso 169.254.169.254 en AWS, GCP y Azure, que pueden devolver detalles de la instancia y, en algunas configuraciones, credenciales temporales del rol de la máquina.
  • Servicios escuchando en localhost, como interfaces de administración, servidores de depuración y endpoints de métricas que dan por hecho que solo les llaman desde la propia máquina.
  • Servicios internos en rangos privados (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) que no tienen autenticación porque nunca se pensó que fueran accesibles desde fuera.
  • Escaneo de puertos y descubrimiento de servicios, usando los tiempos de respuesta o los mensajes de error para mapear la red interna.
  • Protocolos distintos de HTTP, si la librería de descarga admite esquemas como file:, gopher: o ftp:.

Aunque la respuesta no llegue al atacante, un SSRF ciego todavía puede disparar peticiones que cambian el estado contra endpoints internos. Los webhooks suelen ser ciegos, y eso no los hace seguros. Muchos servicios internos aceptan simples peticiones GET que modifican el estado, como purgas de caché o acciones de administración detrás de una URL, precisamente porque asumen que nadie de fuera puede alcanzarlos.

Por qué fallan las comprobaciones simples

El primer instinto es parsear la URL y rechazar hostnames como localhost o cadenas que empiecen por 10. o 192.168. Esto falla por varios motivos.

  • Los hostnames se resuelven a IPs. Un atacante registra un dominio cuyo registro A apunta a 127.0.0.1 o a 169.254.169.254, y una comprobación sobre el hostname no ve nada raro.
  • Las direcciones IP se escriben de muchas formas: decimal (2130706433), hexadecimal, formas cortas como 127.1, e IPv6 con IPv4 mapeada como ::ffff:127.0.0.1. La comparación de cadenas se las pierde.
  • DNS rebinding: el dominio resuelve a una IP pública cuando lo validas y a una IP privada un instante después, cuando el cliente HTTP se conecta. Validar y conectar son dos resoluciones distintas.
  • Redirecciones: la URL que validaste devuelve un 302 hacia http://169.254.169.254/, y el cliente HTTP la sigue sin preguntarte.

El hilo común es que la validación ocurre sobre algo distinto de la dirección a la que realmente se conecta el socket. La corrección es comprobar la IP resuelta en el momento de conectar, en cada conexión, también después de las redirecciones.

Defensas, de más fuerte a menos

Usa una allowlist de destinos cuando puedas

Si la funcionalidad solo necesita hablar con un conjunto conocido de hosts, como una integración con un puñado de proveedores, pon esos hostnames en una allowlist y rechaza todo lo demás. Es el control más fuerte y el más fácil de razonar. Compara el hostname parseado de forma exacta, no con comprobaciones startsWith o endsWith, que aceptan hosts parecidos como api.example.com.attacker.net o evilexample.com. Todo lo que viene a continuación es para funcionalidades que tienen que aceptar URLs públicas arbitrarias.

Restringe el esquema y el puerto

Acepta solo https: (y http: solo si no te queda más remedio). Rechaza las credenciales en la URL y limita los puertos a 443 y 80 salvo que haya una necesidad clara. Parsea con un parser de URL estándar, nunca con una expresión regular; el parser de WHATWG además normaliza las grafías raras de IP, lo que ayuda a las comprobaciones posteriores.

Resuelve y después comprueba la IP al conectar

Bloquea los rangos de loopback, privados, link-local, CGNAT, no especificados y los rangos IPv6 unique-local y link-local. Lo fundamental es aplicar la comprobación dentro de la resolución DNS que usa el cliente HTTP, para que la dirección validada sea la dirección a la que se conecta. Eso cierra el hueco del DNS rebinding. Los módulos http y https de Node aceptan una función lookup personalizada exactamente para esto.

La lista de bloqueo de abajo cubre los rangos que importan en la mayoría de los despliegues. Añade cualquier rango público que pertenezca a tu propia infraestructura, ya que un balanceador de carga o una API interna con IP pública puede seguir confiando en las peticiones que vienen de dentro de tu red. Rechaza un hostname si cualquiera de sus direcciones resueltas está bloqueada, no solo la primera, porque el cliente puede probarlas en cualquier orden.

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4 mapeada
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// Valida todas las direcciones resueltas antes de que el socket conecte
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

Hay un detalle fácil de pasar por alto: cuando la URL contiene una IP literal, Node conecta directamente sin llamar a lookup. Así que la función de petición tiene que comprobar ella misma las IP literales antes de delegar.

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https nunca sigue redirecciones; un 3xx se trata como un fallo
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

Si usas fetch en Node, que está construido sobre undici, la misma idea se aplica mediante un dispatcher personalizado: crea un Agent de undici con la opción connect.lookup y pásalo como dispatcher. El principio no cambia: la comprobación vive donde se establece la conexión. Si tu entorno enruta el tráfico saliente a través de un proxy HTTP, ten en cuenta que la resolución pasa entonces a hacerse en el proxy para el host de destino, así que el propio proxy debe aplicar las mismas reglas.

Desactiva las redirecciones o revalida cada salto

Para los webhooks, no sigas redirecciones en absoluto; un receptor que redirige está mal configurado, y fallar de forma ruidosa le dice al cliente que arregle su endpoint. Para las vistas previas de enlaces y los importadores, donde las redirecciones son normales, síguelas manualmente con un límite pequeño y pasa cada salto por las mismas comprobaciones de esquema, puerto e IP. Con fetch, usa redirect: "manual" y gestiona tú mismo la cabecera Location.

Limita lo que puede hacer una petición correcta

  • Configura timeouts de conexión y totales, y limita el tamaño de la respuesta, para que no se pueda usar el descargador para mantener conexiones abiertas ni para traer archivos enormes.
  • No devuelvas respuestas crudas ni mensajes de error detallados al usuario. Para las vistas previas de enlaces, devuelve solo el título, la descripción y la URL de la imagen extraídos.
  • Elimina tus propias cabeceras de autenticación y cookies; el descargador nunca debería enviar credenciales internas a hosts proporcionados por el usuario.

Enruta a través de un proxy de salida

El montaje más robusto saca la política del código de la aplicación. Ejecuta las descargas salientes dirigidas por el usuario a través de un proxy de salida dedicado, o desde un worker aislado en un segmento de red que sencillamente no tenga ruta hacia los servicios internos ni hacia el endpoint de metadatos. Entonces un fallo en la validación de URL no se convierte en una brecha, porque es la propia red la que se niega. Los proxies directos de código abierto pueden aplicar por ti las reglas de destino, y un worker separado también aísla las respuestas lentas u hostiles de tu proceso web principal.

Endurece el servicio de metadatos

En AWS, exige IMDSv2, que necesita un token de sesión obtenido con una petición PUT, y mantén el límite de saltos en 1 para que los contenedores no puedan alcanzarlo a través del host. GCP y Azure exigen una cabecera concreta en las llamadas a metadatos. Esto sube bastante el listón frente al SSRF simple basado en GET, pero son una segunda capa, no un sustituto de bloquear las direcciones link-local. Da también al rol de la instancia o del servicio solo los permisos que necesita la aplicación, para que unas credenciales robadas a través del servicio de metadatos desbloqueen lo menos posible.

Cómo probar tus defensas

  • Prueba http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ y http://169.254.169.254/latest/meta-data/ y confirma que cada una se rechaza.
  • Apunta un hostname que controles a 127.0.0.1 y confirma que la comprobación en la resolución lo rechaza. Después dale dos registros A, uno público y uno privado, y confirma que se sigue rechazando.
  • Sirve una redirección 302 hacia una dirección privada desde un host público y confirma que no se sigue.
  • Prueba URLs file:, ftp: y gopher:, y URLs con credenciales o con puertos poco habituales.

En la revisión de código, busca todos los sitios donde se construye una URL de petición a partir de una entrada: fetch, axios, got, requests, http.Get, llamadas page.goto de navegadores headless y librerías de procesamiento de imágenes que aceptan URLs. CodeAuditAgent rastrea ese flujo de datos en repositorios públicos y fragmentos pegados y reporta el SSRF como CWE-918 con el punto de llamada citado, un escenario de explotación y un parche.