SSRF Hardening in Node: Block Metadata and DNS Rebinding on Fetches

SSRF Hardening in Node: Block Metadata and DNS Rebinding on Fetches

User-supplied URLs (avatar, webhook, import from URL) turn your Node service into an HTTP client inside the VPC. Attackers aim at 169.254.169.254, link-local, and DNS rebinding. The unfair advantage is pinning the resolved address at connect time—deny private ranges after DNS, not just after parsing the hostname string.

⚡ TL;DR: Parse URL; resolve DNS yourself; reject private/link-local/metadata IPs; connect to the pinned address with Host header; block redirects to private targets; prefer egress proxy allowlists. Pair with Lambda Outbound Proxies and Node Permission Model.

Why hostname denylists fail

1) Attacker URL: http://evil.example/img  → resolves to 1.2.3.4 (public)  ✅ passes check
2) TTL expires / rebind → 169.254.169.254 at connect time               ❌ SSRF
// ✅ Pin address after resolution (sketch)
import dns from "node:dns/promises";
import net from "node:net";
import { Agent, fetch } from "undici";

const BLOCK = [
  /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[0-1])\./, /^192\.168\./,
  /^169\.254\./, /^0\./, /^100\.64\./, /^::1$/, /^fc00:/i, /^fe80:/i,
];

function isBlockedIp(ip: string): boolean {
  return BLOCK.some((re) => re.test(ip));
}

export async function safeFetch(userUrl: string): Promise<Response> {
  const u = new URL(userUrl);
  if (!["http:", "https:"].includes(u.protocol)) throw new Error("scheme");
  const { address, family } = await dns.lookup(u.hostname, { verbatim: true });
  if (isBlockedIp(address)) throw new Error("ssrf_blocked");

  // Connect to pinned IP; send original Host
  const pinned = family === 6 ? `http://[${address}]${u.pathname}${u.search}`
                              : `http://${address}${u.pathname}${u.search}`;
  const httpsUrl = u.protocol === "https:"
    ? pinned.replace("http://", "https://")
    : pinned;

  return fetch(httpsUrl, {
    method: "GET",
    redirect: "manual", // ✅ inspect Location yourself
    headers: { Host: u.host },
    dispatcher: new Agent({ connect: { timeout: 2000 } }),
  });
}
// ❌ Trusting only URL.hostname string checks
if (userUrl.includes("169.254.169.254")) throw new Error("no");
await fetch(userUrl); // rebinding / DNS / redirects still bite

Redirects and IPv6 tricks

Follow redirects manually: re-run the IP check on every Location. Deny decimal/octal IP forms, DNS to @, and credentialed URLs. Prefer an egress proxy (Lambda Outbound Proxies) so the task role never reaches IMDS.

Control Stops
Pin + private IP deny Direct metadata / RFC1918
Manual redirects Open redirect to IMDS
IMDS hop limit / IMDSv2 Host metadata from task
Egress allowlist proxy Defense in depth

Closing checklist

✅ Dos
– ✅ Resolve then deny private/link-local/metadata ranges
– ✅ Pin IP for the TCP connect; set Host explicitly
– ✅ Re-validate on every redirect hop
– ✅ Force IMDSv2 + hop limit on EC2/ECS hosts
– ✅ Default-deny egress except proxy / known peers

❌ Don’ts
– ❌ Don’t fetch(userUrl) with auto-redirect
– ❌ Don’t rely on string denylists alone
– ❌ Don’t allow file:, gopher:, or weird schemes
– ❌ Don’t give the app role broad ec2:Describe* just because SSRF “might need it”

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply