Volver al blog
Node.jsNext.jsSSRFSeguridad de APIServer ComponentsOWASP Top 10Seguridad de BackendSeguridad de Red

Prevención de SSRF en Node.js y Next.js: Guía de Defensa contra Server-Side Request Forgery [2026]

Introducción

Tu Server Component de Next.js llama a fetch("https://api.example.com/data"). Una ruta de API hace de proxy de URLs suministradas por el usuario. Un generador de PDFs acepta un enlace de imagen remota. Cualquiera de estos puede convertirse en un vector de SSRF — y las consecuencias van desde el escaneo de la red interna hasta el robo de credenciales del metadata de la nube.

El Server-Side Request Forgery (SSRF) entró en el OWASP Top 10 (A10:2021) porque la infraestructura en la nube y el renderizado en el servidor lo hicieron a la vez más común y más peligroso. En 2026, con los Server Components de Next.js obteniendo datos en el servidor por defecto, la superficie de ataque es mayor que nunca.

En esta guía aprenderás:

  • Cómo se ve el SSRF en aplicaciones reales de Next.js y Node.js
  • Por qué la validación de URLs es más difícil de lo que parece (trampas del parseo de URLs, DNS rebinding, cadenas de redirecciones)
  • Una estrategia de defensa de nivel producción con código que puedes copiar a tu proyecto
  • Cómo probar tus defensas contra SSRF con herramientas prácticas

¿Qué es el SSRF?

El Server-Side Request Forgery ocurre cuando un atacante engaña a tu servidor para que haga peticiones a destinos no previstos. El servidor tiene acceso de red que el atacante no tiene — servicios internos, endpoints de metadata de la nube, bases de datos, orquestadores de contenedores — y el SSRF convierte tu servidor en un proxy.

Ejemplo simple en una ruta de API de Next.js:

tsx
// app/api/proxy/route.ts — VULNERABLE
export async function GET(request: NextRequest) {
  const url = request.nextUrl.searchParams.get("url");
  const response = await fetch(url as string);
  const data = await response.text();
  return new Response(data);
}

Un atacante llama a /api/proxy?url=http://169.254.169.254/latest/meta-data/ y obtiene las credenciales de tu instancia AWS.

La misma vulnerabilidad en un Server Component:

tsx
// app/page.tsx — VULNERABLE si url proviene de entrada del usuario
export default async function Page({ searchParams }) {
  const { url } = await searchParams;
  const res = await fetch(url);
  const data = await res.json();
  return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Los Server Components de Next.js obtienen datos en tiempo de petición en el servidor. Cualquier parámetro url pasado a fetch() que se origine en entrada del usuario — query params, datos de formulario, cabeceras, cookies — es un vector potencial de SSRF.

Vectores de Ataque: Qué Objetivos Buscan los Atacantes

1. Endpoints de Metadata de la Nube

Los objetivos de SSRF más dañinos. Los proveedores de nube exponen el metadata de la instancia en IPs conocidas:

| Proveedor | Endpoint de Metadata | Lo que Expone | |-----------|--------------------------------|------------------------------------------| | AWS | http://169.254.169.254/latest/meta-data/ | Credenciales IAM, ID de instancia, región | | GCP | http://metadata.google.internal/computeMetadata/v1/ | Tokens de cuentas de servicio, información del proyecto | | Azure | http://169.254.169.254/metadata/instance?api-version=2021-02-01 | Tokens de managed identity, configuración de VM | | DigitalOcean | http://169.254.169.254/metadata/v1.json | Metadata de Droplet, user data |

2. Escaneo de la Red Interna

bash
# El atacante sondea servicios internos a través de tu proxy
http://localhost:3000/
http://127.0.0.1:3000/
http://[::1]:3000/              # Loopback IPv6
http://0.0.0.0:3000/           # Todas las interfaces
http://10.0.0.1:9200/          # Elasticsearch en subred privada
http://192.168.1.1:5432/       # PostgreSQL en VPC
http://172.16.0.1:6379/        # Redis en bridge de Docker

3. Técnicas de Ofuscación de URLs

Los atacantes no envían http://169.254.169.254/ en texto plano. Usan:

text
# IP decimal
http://2852039166/          # 169.254.169.254 en decimal

# IP octal
http://0251.0376.0251.0376/ # 169.254.169.254 en octal

# IP hexadecimal
http://0xA9.0xFE.0xA9.0xFE/ # 169.254.169.254 en hexadecimal

# Radix mixto
http://0251.254.0xA9.376/

# Trucos de DNS
http://169.254.169.254.nip.io/   # Resolvedor DNS que devuelve la IP
http://1.1.1.1.nip.io:80@evil.com/ # Confusión de URLs estilo credencial

# Normalización Unicode
http://①②⑨.②⑤④.①⑥⑨.②⑤④/  # Equivalentes Unicode (truco antiguo, aún funciona en algunos parsers)

# Cadenas de redirecciones
https://open-redirect.example.com/redirect?to=http://169.254.169.254/latest/meta-data/

4. DNS Rebinding

El bypass de SSRF más avanzado. El atacante controla un dominio que inicialmente resuelve a una IP segura (superando tu validación) y, después de la comprobación de validación, cambia el registro DNS a una IP interna.

text
Tiempo T:  evil.com → 1.2.3.4 (seguro — supera la comprobación de lista blanca)
Tiempo T+1: evil.com → 169.254.169.254 (resolve() se llama durante fetch, no durante la validación)

El servidor valida evil.com contra la lista blanca cuando T=0, pero fetch() llama a dns.lookup() en T+1 — obteniendo la IP interna. Por eso validar antes del fetch no es suficiente sin salvaguardas adicionales.

Estrategia de Defensa: Defensa en Profundidad

La prevención de SSRF requiere defensas en capas. Ninguna comprobación única es suficiente. Esta es la estrategia completa:

Capa 1: Validación de URLs y Lista Blanca

Si conoces los hosts esperados, ponlos en una lista blanca. Esta es la defensa más fuerte.

tsx
// lib/ssrf/allowlist.ts
const ALLOWED_HOSTS = new Set([
  "api.example.com",
  "cdn.example.com",
  "api.stripe.com",
  "api.github.com",
]);

export function validateAgainstAllowlist(url: string): {
  valid: boolean;
  error?: string;
} {
  try {
    const parsed = new URL(url);
    if (!ALLOWED_HOSTS.has(parsed.hostname)) {
      return { valid: false, error: `Host '${parsed.hostname}' is not allowlisted` };
    }
    return { valid: true };
  } catch {
    return { valid: false, error: "Invalid URL" };
  }
}

Cuando la lista blanca no es posible (el usuario proporciona URLs arbitrarias, como en un proxy de webhooks o un previsualizador de enlaces), usa listas negras con validación estricta.

Capa 2: Parseo de URLs — No Confíes en tu Parser de URLs

El constructor URL de Node.js es bueno pero no infalible. Algunos ataques explotan las diferencias entre cómo new URL() y fetch() resuelven las direcciones.

Crítico: resuelve siempre la IP final, no solo el hostname.

tsx
// lib/ssrf/validate.ts
import { lookup } from "net";
import { isIP } from "net";

const PRIVATE_RANGES = [
  // Rangos privados IPv4
  { prefix: "10.", type: "ipv4" },
  { prefix: "127.", type: "ipv4" },
  { prefix: "169.254.", type: "ipv4" },
  { prefix: "172.16.", type: "ipv4" },
  { prefix: "192.168.", type: "ipv4" },
  { prefix: "0.", type: "ipv4" },
  // IPv6
  { prefix: "::1", type: "ipv6" },
  { prefix: "::", type: "ipv6" },
  { prefix: "fc", type: "ipv6" },   // fc00::/7 — unique local
  { prefix: "fd", type: "ipv6" },   // fd00::/7 — unique local
  { prefix: "fe80", type: "ipv6" }, // link-local
];

function isPrivateIP(ip: string): boolean {
  return PRIVATE_RANGES.some((range) => ip.startsWith(range.prefix));
}

export async function resolveAndValidate(url: URL): Promise<{
  valid: boolean;
  error?: string;
  resolvedIP?: string;
}> {
  // Paso 1: Rechazar si no hay protocolo o no es http(s)
  if (!["http:", "https:"].includes(url.protocol)) {
    return { valid: false, error: "Only http and https protocols are allowed" };
  }

  // Paso 2: Rechazar si hay credenciales presentes (intento de bypass)
  if (url.username || url.password) {
    return { valid: false, error: "URL credentials are not allowed" };
  }

  // Paso 3: Rechazar hosts en forma de IP que sean privados
  const hostname = url.hostname;
  if (isIP(hostname)) {
    if (isPrivateIP(hostname)) {
      return { valid: false, error: "Private IP addresses are not allowed" };
    }
    return { valid: true, resolvedIP: hostname };
  }

  // Paso 4: Resolver DNS — AQUÍ es donde puede ocurrir el DNS rebinding
  const resolvedIP = await new Promise<string>((resolve, reject) => {
    lookup(hostname, { family: 4 }, (err, address) => {
      if (err) reject(err);
      else resolve(address);
    });
  });

  if (isPrivateIP(resolvedIP)) {
    return {
      valid: false,
      error: `Resolved IP '${resolvedIP}' is a private address`,
      resolvedIP,
    };
  }

  // Paso 5: Comprobar contra IPs de metadata de nube conocidas (cinturón y tirantes)
  if (resolvedIP === "169.254.169.254") {
    return { valid: false, error: "Cloud metadata endpoint is blocked", resolvedIP };
  }

  return { valid: true, resolvedIP };
}

Trampa: lookup() usa el resolvedor del sistema, que puede respetar /etc/hosts. Si un atacante tiene cualquier capacidad de modificar el archivo de hosts (contenedor comprometido, hosting compartido), la resolución DNS no es confiable. En ese caso, usa un resolvedor DNS-over-HTTPS para rutas críticas.

Capa 3: Bloquear Cadenas de Redirecciones

El SSRF usa a menudo una redirección abierta como trampolín. Tu servidor valida la URL inicial, fetch() sigue un 302, y de repente estás golpeando http://169.254.169.254/.

tsx
// lib/ssrf/fetch.ts
import { resolveAndValidate } from "./validate";

interface SafeFetchOptions {
  maxRedirects?: number;
  timeout?: number;
  validateRedirect?: boolean;
}

export async function safeFetch(
  url: string,
  options: SafeFetchOptions = {}
): Promise<Response> {
  const {
    maxRedirects = 0,      // No seguir redirecciones por defecto
    timeout = 5000,
    validateRedirect = true,
  } = options;

  const parsed = new URL(url);
  
  // Validar la URL inicial
  const initialCheck = await resolveAndValidate(parsed);
  if (!initialCheck.valid) {
    throw new Error(`SSRF check failed: ${initialCheck.error}`);
  }

  // Usar AbortController para el timeout
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      signal: controller.signal,
      redirect: "manual",  // NO seguir redirecciones automáticamente
    });

    // Validar manualmente los destinos de las redirecciones
    if (response.status >= 300 && response.status < 400) {
      const location = response.headers.get("location");
      if (!location) {
        throw new Error("Redirect without Location header");
      }

      // Validar el destino de la redirección
      const redirectUrl = new URL(location, url); // resolver redirecciones relativas
      const redirectCheck = await resolveAndValidate(redirectUrl);
      if (!redirectCheck.valid) {
        throw new Error(`Redirect target blocked: ${redirectCheck.error}`);
      }

      if (maxRedirects <= 0) {
        // Devolver la información de redirección sin seguirla
        return response;
      }

      // Seguir recursivamente con contador decrementado
      return safeFetch(location, {
        ...options,
        maxRedirects: maxRedirects - 1,
      });
    }

    return response;
  } finally {
    clearTimeout(timer);
  }
}

Decisión clave: Establece redirect: "manual" en todas las llamadas internas a fetch() que procesen URLs suministradas por el usuario. Esto te da control sobre qué redirecciones seguir.

Capa 4: Protección contra DNS Rebinding

El DNS rebinding es difícil de prevenir solo con validación porque la resolución DNS ocurre dos veces — una en tu validador y otra en fetch(). Si el registro DNS cambia entre esas llamadas, tu validación es inútil.

Solución A: Fijar la IP antes de hacer el fetch

tsx
// lib/ssrf/pinned-fetch.ts
import { createConnection } from "net";
import { request } from "http";
import { request as httpsRequest } from "https";

export async function pinnedFetch(url: string): Promise<Response> {
  const parsed = new URL(url);
  
  // Resolver DNS AHORA y validar
  const ip = await resolveHostname(parsed.hostname);
  if (isPrivateIP(ip)) {
    throw new Error("Blocked private IP");
  }

  // "Fijar" la IP conectando directamente, omitiendo el DNS
  // Fetch con IP explícita y cabecera Host
  const actualUrl = `${parsed.protocol}//${ip}${parsed.pathname}${parsed.search}`;
  const headers = {
    ...(parsed.hostname && { Host: parsed.hostname }),
  };

  const controller = new AbortController();
  const response = await fetch(actualUrl, {
    headers,
    signal: controller.signal,
  });

  return response;
}

Este enfoque resuelve el DNS una vez y se conecta directamente a la IP, omitiendo por completo la segunda resolución DNS. El DNS rebinding se vuelve imposible porque solo hay una búsqueda DNS.

Solución B: Usar agent con DNS personalizado

tsx
import { Resolver } from "dns/promises";
import { Agent } from "http";

const dnsResolver = new Resolver();
dnsResolver.setServers(["1.1.1.1"]); // Usar upstream DNS-over-HTTPS

class SSRFGuardAgent extends Agent {
  async createConnection(options: any, cb: Function) {
    // Resolver siempre a través de nuestro DNS seguro
    const { hostname } = options;
    const { address } = await dnsResolver.resolve4(hostname);
    
    if (isPrivateIP(address)) {
      cb(new Error(`SSRF: blocked private IP ${address}`));
      return;
    }
    
    options.host = address; // Conectar a la IP resuelta
    options.servername = hostname; // Mantener SNI para HTTPS
    return super.createConnection(options, cb);
  }
}

Capa 5: Bloqueo a Nivel de Red (Defensa en Profundidad)

El código es bueno. Pero el bloqueo a nivel de red es la póliza de seguro si tu código tiene un bug.

Para despliegues Docker:

dockerfile
# Bloquear el tráfico saliente a endpoints de metadata a nivel de red
# Ejecutar el contenedor con reglas iptables vía --cap-add=NET_ADMIN
RUN iptables -A OUTPUT -d 169.254.169.254 -j DROP
RUN iptables -A OUTPUT -d 169.254.169.254/32 -j DROP
# Bloquear rangos link-local y privados para tráfico saliente
RUN iptables -A OUTPUT -d 10.0.0.0/8 -j DROP
RUN iptables -A OUTPUT -d 172.16.0.0/12 -j DROP
RUN iptables -A OUTPUT -d 192.168.0.0/16 -j DROP
RUN iptables -A OUTPUT -d 127.0.0.0/8 -j DROP

Para Kubernetes, usa NetworkPolicies:

yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-metadata
spec:
  podSelector:
    matchLabels:
      app: my-app
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 10.0.0.0/8
        - 172.16.0.0/12
        - 192.168.0.0/16
        - 169.254.0.0/16

Librería de Validación SSRF Lista para Producción

Déjame ponerlo todo junto en un módulo reutilizable que puedes dejar caer en cualquier proyecto Next.js:

tsx
// lib/ssrf/index.ts
import { lookup } from "net";
import { isIP } from "net";
import { URL } from "url";

// --- Configuración ---
const BLOCKED_IPS = new Set([
  "0.0.0.0",
  "127.0.0.1",
  "::1",
  "::",
  "169.254.169.254",
]);

const PRIVATE_RANGES = [
  { ip: 0x0a000000, mask: 0xff000000, name: "10.0.0.0/8" },       // 10.0.0.0/8
  { ip: 0x7f000000, mask: 0xff000000, name: "127.0.0.0/8" },      // 127.0.0.0/8
  { ip: 0xa9fe0000, mask: 0xffff0000, name: "169.254.0.0/16" },   // 169.254.0.0/16
  { ip: 0xac100000, mask: 0xfff00000, name: "172.16.0.0/12" },    // 172.16.0.0/12
  { ip: 0xc0a80000, mask: 0xffff0000, name: "192.168.0.0/16" },   // 192.168.0.0/16
  { ip: 0x00000000, mask: 0x00000000, name: "0.0.0.0/32" },       // 0.0.0.0/32
];

function ipv4ToInt(ip: string): number {
  return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
}

function isPrivateIP(ip: string): boolean {
  if (BLOCKED_IPS.has(ip)) return true;

  if (isIP(ip) === 4) {
    const intIP = ipv4ToInt(ip);
    return PRIVATE_RANGES.some((range) => (intIP & range.mask) === range.ip);
  }

  // Comprobaciones IPv6
  if (isIP(ip) === 6) {
    const normalized = ip.toLowerCase();
    if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // ULA
    if (normalized.startsWith("fe80")) return true; // Link-local
    if (normalized === "::1") return true;          // Loopback
  }

  return false;
}

export interface SSRFResult {
  valid: boolean;
  error?: string;
  hostname: string;
  resolvedIP?: string;
}

export async function validateURL(input: string): Promise<SSRFResult> {
  // 1. Parsear la URL
  let parsed: URL;
  try {
    parsed = new URL(input);
  } catch {
    return { valid: false, error: "Invalid URL", hostname: input };
  }

  // 2. Comprobación de protocolo
  if (!["http:", "https:"].includes(parsed.protocol)) {
    return { valid: false, error: "Only http/https URLs allowed", hostname: parsed.hostname };
  }

  // 3. Rechazar URLs con credenciales incrustadas
  if (parsed.username || parsed.password) {
    return { valid: false, error: "URL must not contain credentials", hostname: parsed.hostname };
  }

  // 4. Rechazar URLs excesivamente largas (posible tunneling de DNS)
  if (parsed.hostname.length > 253) {
    return { valid: false, error: "Hostname too long", hostname: parsed.hostname };
  }

  const hostname = parsed.hostname;

  // 5. Comprobación directa de IP
  if (isIP(hostname)) {
    if (isPrivateIP(hostname)) {
      return { valid: false, error: "Private IP range blocked", hostname, resolvedIP: hostname };
    }
    return { valid: true, hostname, resolvedIP: hostname };
  }

  // 6. Resolución DNS
  try {
    const resolvedIP = await new Promise<string>((resolve, reject) => {
      lookup(hostname, { family: 4, hints: 0 }, (err, address) => {
        if (err) reject(err);
        else resolve(address);
      });
    });

    if (isPrivateIP(resolvedIP)) {
      return {
        valid: false,
        error: `Resolved to blocked IP range: ${resolvedIP}`,
        hostname,
        resolvedIP,
      };
    }

    return { valid: true, hostname, resolvedIP };
  } catch (error: any) {
    return { valid: false, error: `DNS resolution failed: ${error.message}`, hostname };
  }
}

Uso en una ruta de API de Next.js:

tsx
// app/api/fetch-image/route.ts
import { NextRequest, NextResponse } from "next/server";
import { validateURL } from "@/lib/ssrf";

export async function GET(request: NextRequest) {
  const imageUrl = request.nextUrl.searchParams.get("url");

  if (!imageUrl) {
    return NextResponse.json({ error: "Missing 'url' parameter" }, { status: 400 });
  }

  // Validación SSRF
  const validation = await validateURL(imageUrl);
  if (!validation.valid) {
    return NextResponse.json(
      { error: `URL rejected: ${validation.error}` },
      { status: 403 }
    );
  }

  // Fetch seguro con control de redirecciones
  try {
    const response = await fetch(imageUrl, {
      redirect: "manual",
      signal: AbortSignal.timeout(5000),
    });

    // Validar cualquier destino de redirección
    if (response.status >= 300 && response.status < 400) {
      const location = response.headers.get("location");
      if (location) {
        const redirectValidation = await validateURL(new URL(location, imageUrl).href);
        if (!redirectValidation.valid) {
          return NextResponse.json(
            { error: `Redirect rejected: ${redirectValidation.error}` },
            { status: 403 }
          );
        }
        // Seguir la redirección
        return NextResponse.redirect(new URL(location, imageUrl));
      }
    }

    // Devolver el contenido
    const buffer = await response.arrayBuffer();
    return new NextResponse(buffer, {
      headers: {
        "Content-Type": response.headers.get("content-type") || "application/octet-stream",
        "Cache-Control": "public, max-age=86400",
      },
    });
  } catch (error) {
    return NextResponse.json({ error: "Fetch failed" }, { status: 502 });
  }
}

Uso en un Server Component:

tsx
// app/preview/page.tsx
import { validateURL } from "@/lib/ssrf";

export default async function LinkPreviewPage({
  searchParams,
}: {
  searchParams: Promise<{ url: string }>;
}) {
  const { url } = await searchParams;

  if (!url) {
    return <p>Missing URL</p>;
  }

  // Validar antes de obtener — incluso en Server Components
  const validation = await validateURL(url);
  if (!validation.valid) {
    return <p className="text-red-500">Blocked: {validation.error}</p>;
  }

  // Solo ahora obtener el contenido externo
  const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
  const html = await response.text();

  // ... renderizar la vista previa ...
}

SSRF en Server Actions

Las Server Actions también hacen llamadas fetch y pueden recibir URLs de envíos de formularios:

tsx
// app/actions.ts
"use server";

import { validateURL } from "@/lib/ssrf";

export async function importExternalData(formData: FormData) {
  const url = formData.get("sourceUrl") as string;

  // La validación SSRF es obligatoria aquí
  const validation = await validateURL(url);
  if (!validation.valid) {
    return { error: validation.error };
  }

  const response = await fetch(url, { redirect: "manual" });
  const data = await response.json();
  // ... procesar datos ...
}

SSRF desde Servicios Internos

El SSRF no siempre se trata de URLs suministradas por el usuario. Cualquier servicio al que tu backend llame con un parámetro controlado por el atacante es un vector:

  • Receptores de webhooks que devuelven URLs en las respuestas
  • Resolvedores DNS que obtienen registros TXT de dominios del atacante
  • Flujos SSO/OAuth que redirigen a URLs arbitrarias
  • Procesadores de archivos que obtienen recursos remotos (generadores de PDF, redimensionadores de imágenes)
  • Navegadores headless usados para servicios de capturas de pantalla

Checklist: audita tu aplicación en busca de estos patrones:

  • [ ] Todas las llamadas fetch() que aceptan un parámetro URL de entrada del usuario están validadas
  • [ ] Las redirecciones están en "manual" y cada destino de redirección se re-valida
  • [ ] La resolución DNS se hace antes del fetch, no después
  • [ ] Las descargas de archivos desde URLs proporcionadas por el usuario rechazan cadenas de redirecciones
  • [ ] Los handlers de webhooks no hacen de proxy de la URL del payload del webhook sin validación
  • [ ] Los Server Components que leen searchParams y llaman a fetch() validan la URL
  • [ ] Las Server Actions que aceptan URLs en datos de formulario las validan
  • [ ] Los endpoints de proxy de metadata están en lista blanca, no en lista negra, cuando es posible

Probando tus Defensas SSRF

Pruebas en desarrollo con un servidor local

bash
# Iniciar un listener interno simple para probar si el SSRF está bloqueado
python3 -m http.server 9999

# Intentar acceder a él a través de tu aplicación
curl "http://localhost:3000/api/fetch-image?url=http://127.0.0.1:9999/"
curl "http://localhost:3000/api/fetch-image?url=http://0.0.0.0:9999/"
curl "http://localhost:3000/api/fetch-image?url=http://[::1]:9999/"

Pruebas automatizadas

tsx
// tests/ssrf.test.ts
import { validateURL } from "@/lib/ssrf";

describe("SSRF validation", () => {
  const privateIPs = [
    "http://127.0.0.1:3000/",
    "http://10.0.0.1:9200/",
    "http://169.254.169.254/latest/meta-data/",
    "http://192.168.1.1:5432/",
    "http://0.0.0.0:3000/",
    "http://[::1]:3000/",
  ];

  it.each(privateIPs)("blocks private IP: %s", async (url) => {
    const result = await validateURL(url);
    expect(result.valid).toBe(false);
  });

  const allowedURLs = [
    "https://api.example.com/v1/data",
    "https://cdn.example.com/image.png",
    "http://httpbin.org/get",
  ];

  it.each(allowedURLs)("allows public URLs: %s", async (url) => {
    const result = await validateURL(url);
    expect(result.valid).toBe(true);
  });

  it("rejects URLs with embedded credentials", async () => {
    const result = await validateURL("http://user:pass@evil.com/");
    expect(result.valid).toBe(false);
  });

  it("rejects non-http protocols", async () => {
    const result = await validateURL("file:///etc/passwd");
    expect(result.valid).toBe(false);
  });

  it("rejects IP-based localhost alternatives", async () => {
    // Representación decimal
    const result = await validateURL(`http://${ipToDecimal("127.0.0.1")}/`);
    expect(result.valid).toBe(false);
  });
});

Pruebas en producción con SSRF Map

Usa SSRF Map o una herramienta similar para repasar las técnicas de bypass comunes:

bash
# Instalar SSRF Map
git clone https://github.com/swisskyrepo/SSRFmap
cd SSRFmap && pip install -r requirements.txt

# Probar tu endpoint
python3 ssrfmap.py -t "http://localhost:3000/api/proxy?url=URLHERE"

Librerías de Terceros

Si no quieres construir la tuya, estas librerías manejan la validación SSRF:

| Librería | Descripción | |---------|-------------| | ssrf-req-filter | Intercepta peticiones http/https de Node.js y bloquea IPs privadas | | ip-range-check | Comprueba si una IP cae dentro de rangos CIDR | | axios-ssrf | Adaptador de Axios que valida URLs antes de conectar | | undici | Cliente HTTP de Node.js (usado por fetch) — puede usar dispatchers personalizados |

Ejemplo con ssrf-req-filter:

tsx
import { SSRFGuard } from "ssrf-req-filter";
import http from "http";
import https from "https";

// Parchear los módulos globales http/https
SSRFGuard.protect();

// Ahora TODAS las peticiones http/https están validadas
// Las IPs privadas, endpoints de metadata, etc. se bloquean automáticamente

// Para Next.js, parchearías en next.config.ts o en la inicialización de la app

Advertencia: Parchear el módulo global http/https funciona para http.request() pero NO para fetch(), que usa undici internamente. Para fetch() necesitas validación explícita (nuestro enfoque validateURL() anterior).

Resumen: Checklist de Prevención de SSRF

  • [ ] Lista blanca cuando sea posible en lugar de lista negra — es el único enfoque realmente seguro
  • [ ] Nunca confíes en URLs suministradas por el usuario — valida todas contra DNS e IP a la vez
  • [ ] Resuelve el DNS y valida la IP antes de hacer la petición real
  • [ ] Establece redirect: "manual" en todos los fetches que involucren URLs controladas por el usuario
  • [ ] Valida cada destino de redirección individualmente al seguir cadenas de redirecciones
  • [ ] Fija las IPs para prevenir el DNS rebinding en rutas críticas (proxies de metadata, webhooks)
  • [ ] Establece timeouts de petición — el SSRF puede colgarse en hosts internos que aceptan TCP pero no responden
  • [ ] Usa políticas de red (iptables, NetworkPolicy de Kubernetes, Security Groups de AWS) como red de seguridad
  • [ ] Registra los intentos bloqueados — los bloqueos inesperados de SSRF suelen ser la primera señal de reconocimiento
  • [ ] Prueba tus defensas con las mismas técnicas de ofuscación que usan los atacantes

El SSRF es la vulnerabilidad que sigue apareciendo en las pesadillas de los CTOs porque una sola URL sin validar puede exponer toda una infraestructura en la nube. En la era de Next.js donde los Server Components obtienen datos por defecto, todo desarrollador que trabaje con código de servidor necesita la defensa contra SSRF como habilidad central — no como una preocupación de nicho.

La próxima semana: Nos sumergiremos en la seguridad de GraphQL — ataques de batching, limitación de profundidad, autorización en resolvers y endurecimiento de producción para servidores Apollo y Yoga. Suscríbete abajo para no perdértelo.

Comparte este artículo:TwitterLinkedIn
JS

JS Security Audit

Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.