Protectores SSRF
Evita la falsificación de solicitudes del lado del servidor (SSRF): bloquea las solicitudes a IPs internas, puntos finales de metadatos en la nube y redes privadas cuando los usuarios proporcionan URLs.
Busca en todas las páginas de la documentación
Evita la falsificación de solicitudes del lado del servidor (SSRF): bloquea las solicitudes a IPs internas, puntos finales de metadatos en la nube y redes privadas cuando los usuarios proporcionan URLs.
Tarjeta de receta de referencia rápida: lista para copiar y pegar.
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
const BLOCKED = new Set(["private", "loopback", "linkLocal", "uniqueLocal", "carrierGradeNat"]);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Invalid protocol");
const { address } = await lookup(url.hostname);
const range = ipaddr.parse(address).range();
if (BLOCKED.has(range)) throw new Error("Blocked destination");
return url;
}Cuándo usarlo:
fetch(userSuppliedUrl) en el código base.import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
const METADATA_HOSTS = new Set(["169.254.169.254", "metadata.google.internal"]);
const ALLOWED_HOSTS = new Set(["api.stripe.com", "hooks.slack.com"]);
const BLOCKED_RANGES = new Set([
"private", "loopback", "linkLocal", "uniqueLocal", "carrierGradeNat", "multicast",
]);
async function resolveAndValidate(hostname: string): Promise<void> {
if (METADATA_HOSTS.has(hostname)) throw new SsrfError("Blocked host");
const records = await lookup(hostname, { all: true });
for (const { address } of records) {
const range = ipaddr.parse(address).range();
if (BLOCKED_RANGES.has(range)) throw new SsrfError(`Blocked IP range: ${range}`);
}
}
class SsrfError extends Error {
constructor(message: string) {
super(message);
this.name = "SsrfError";
}
}
async function safeFetch(raw: string, init?: RequestInit): Promise<Response> {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) throw new SsrfError("Invalid protocol");
if (process.env.SSRF_ALLOWLIST === "true" && !ALLOWED_HOSTS.has(url.hostname)) {
throw new SsrfError("Host not allowlisted");
}
await resolveAndValidate(url.hostname);
return fetch(url, {
...init,
redirect: "manual",
signal: AbortSignal.timeout(init?.signal ? undefined : 5_000),
});
}
// Ruta de Express
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
app.post("/import", async (req, res) => {
const { sourceUrl } = z.object({ sourceUrl: z.string().url() }).parse(req.body);
try {
const response = await safeFetch(sourceUrl);
if (response.status >= 300 && response.status < 400) {
return res.status(400).json({ error: "Redirects not allowed" });
}
res.json({ size: (await response.arrayBuffer()).byteLength });
} catch (err) {
if (err instanceof SsrfError) return res.status(400).json({ error: err.message });
throw err;
}
});Lo que esto demuestra:
10.x o 127.0.0.1.redirect: "manual" evita el bypass a través de 302 a una IP interna.AbortSignal.timeout limita las llamadas salientes colgadas.169.254.169.254 exponen credenciales de IAM en AWS.| Capa | Control |
|---|---|
| Protocolo | Solo http: y https: - bloquea file:, gopher: |
| Nombre de host | Bloquea hosts de metadatos; permite cuando sea posible |
| DNS | Resuelve todos los registros A/AAAA; bloquea rangos privados |
| Redirección | manual + rechaza 3xx o revalida Location |
| Egreso | Política de red / lista de permitidos de egreso del firewall (defensa en profundidad) |
const parsed = ipaddr.parse(address);
if (parsed.kind() === "ipv6") {
const ipv4 = parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed;
// check range on mapped form
}::1, link-local fe80::/10 y direcciones privadas mapeadas a IPv4.const ALLOWED = /\.stripe\.com$/;
if (!ALLOWED.test(url.hostname)) throw new SsrfError("Not allowed");internal.service se resuelve en 10.x. Solución: siempre resuelve el DNS.redirect: "manual".http://127.0.0.1. Solución: bloquea o prohíbe completamente los hosts IP.safeFetch centralizado.fetch( y axios.get en CI.| Alternativa | Usar cuándo | No usar cuándo |
|---|---|---|
| Resolución DNS + bloqueo de IP | Funciones generales de importación de URL | Conjunto de socios fijos (usar lista de permitidos) |
| Lista de permitidos de dominio | Proveedores de webhooks conocidos | URLs de usuario arbitrarias |
| Proxy de egreso | Red empresarial de confianza cero | MVP SaaS simple |
| Sin fetch del lado del servidor | El cliente puede descargar directamente | Necesidad de procesamiento en el servidor |
No. new URL() acepta http://10.0.0.1 y http://169.254.169.254. Resuelve y bloquea rangos.
Las mismas reglas: httpAgent personalizado, maxRedirects: 0, valida la URL antes de la solicitud.
Sí, por defecto. Pasa redirect: "manual" cuando el usuario proporciona la URL.
Paquetes como ssrf-req-filter ayudan. Aun así, agrega una lista de permitidos y tiempos de espera para una defensa en profundidad.
No es SSRF si la URL está impulsada por la configuración, no por el usuario. Usa mTLS y DNS privado.
Entrantes: verifica las firmas. SSRF saliente es cuando TÚ obtienes URLs de usuario, una amenaza diferente.
Bloquea 169.254.169.254 y restringe el egreso de pods con NetworkPolicy.
Pruebas unitarias con 127.0.0.1, 10.0.0.1 y la IP de metadatos; espera un rechazo antes de que se active el mock de fetch.
Versiones de la pila: Esta página fue escrita para Node.js 24.18.0 (LTS activa), npm 10+, TypeScript 5.6+, Express 5, Fastify 5 y NestJS 11.
Revisado por Chris St. John·Última actualización: 19 jul 2026