7 min read

SSRF prevention in Node.js — validate the resolved IP, not the URL string

SSRF (Server-Side Request Forgery) is how attackers reach your cloud metadata service and internal APIs through your public endpoints. Here is the correct defense.

SSRF defenses that check the URL string for `localhost` or `127.0.0.1` are all defeated by clever encodings. The only reliable defense is to resolve the hostname to an IP, reject private IP ranges, then issue the request.

What it is

SSRF happens when your server accepts a user-supplied URL and makes an outbound request to it. If the URL resolves to a private IP (AWS metadata, internal DB, loopback), the attacker has extracted value from your infrastructure's trust boundary.

Vulnerable example

// Vulnerable: string-based allowlist is trivially bypassable
import { URL } from "url";

function isPrivate(url: string) {
  const u = new URL(url);
  const blocked = ["localhost", "127.0.0.1", "169.254.169.254"];
  return blocked.includes(u.hostname);
}

export async function GET(req: Request) {
  const u = new URL(req.url).searchParams.get("url")!;
  if (isPrivate(u)) return new Response("nope", { status: 400 });
  // Attacker URL: http://[::]:80/  or  http://0x7f.0x0.0x0.0x1/
  // passes the check, resolves to loopback.
  const resp = await fetch(u);
  return new Response(await resp.text());
}

Fixed example

// Fixed: resolve, validate IP, then fetch.
import dns from "dns/promises";
import net from "net";

const PRIVATE_RANGES = [
  "10.", "172.16.", "172.17.", "172.31.", "192.168.",
  "127.", "169.254.", "::1", "fc00:", "fe80:",
];

async function isPublicHttp(u: URL) {
  if (!/^https?:$/.test(u.protocol)) return false;
  const ips = await dns.resolve(u.hostname);
  return ips.every((ip) => {
    if (!net.isIP(ip)) return false;
    if (PRIVATE_RANGES.some((p) => ip.startsWith(p))) return false;
    return true;
  });
}

export async function GET(req: Request) {
  const u = new URL(new URL(req.url).searchParams.get("url")!);
  if (!(await isPublicHttp(u))) return new Response("nope", { status: 400 });
  const resp = await fetch(u, { redirect: "error", signal: AbortSignal.timeout(3000) });
  return new Response(await resp.text());
}

How Securie catches it

Securie's SSRF specialist traces every outbound fetch with a user-controlled URL and checks for hostname-string allowlists (which it flags) versus resolved-IP allowlists (which it approves).

Checklist

  • Resolve hostname to IP before issuing the request
  • Reject all private / loopback / link-local / metadata-service IPs
  • Disable redirects (redirect: 'error') or re-validate the target after every redirect
  • Enforce a short timeout (AbortSignal.timeout(3000))
  • Use a dedicated egress proxy with an allowlist for high-security contexts
  • Log every fetch destination for audit

FAQ

What about the AWS metadata IMDSv2?

IMDSv2 requires a token header. Your SSRF attacker cannot set headers; IMDSv2 defeats the classic metadata attack. Ensure IMDSv1 is disabled on every EC2 instance.

Does the `ip` package protect me?

Only if you update it past CVE-2024-29415. Previous versions allowed octal/hex bypass.