Backend & APIs

SSRF Protection in Node.js: Validating User URLs, Redirects and DNS (Tested)

A regex on the hostname is not SSRF protection. I ran 16 bypass payloads against three checks on Node.js 20 and show the approach that holds up: new URL(), net.BlockList, re-checking every redirect hop and validating the DNS answer at connect time.

Code editor card showing a Node.js net.BlockList for private IP ranges, with a decimal IP and a redirect to 169.254.169.254 both blocked
On this page
  1. What SSRF is and who is exposed
  2. The test: three checks, one payload list
  3. Checking IP addresses with net.BlockList
  4. Check every redirect hop
  5. DNS rebinding: check the address you actually connect to
  6. Gaps I found in my own blocklist
  7. What it costs
  8. Tests to keep in CI
  9. Common mistakes
  10. Key takeaways

If your server fetches a URL that a user typed in, you have an SSRF problem until you prove otherwise. Link previews, webhook senders, "import from URL" buttons, image proxies and video downloaders all do it. Point one of them at http://169.254.169.254/latest/meta-data/ on a cloud VM, or at an admin panel on 127.0.0.1, and your server becomes the attacker's browser inside your network.

This post is about the approach I use in Snagbox, my video download API, where users paste links from social media all day. I ran three different checks against the same list of bypass payloads on Node.js 20.19 to show where each one breaks. The short version: a regex on the hostname is not SSRF protection. You need to check the parsed host, every redirect hop, and the IP address the DNS lookup actually returns.

What SSRF is and who is exposed#

Server-Side Request Forgery happens when an attacker controls where your server sends a request. The request comes from inside your network, so it can reach things the attacker cannot reach directly:

  • Cloud metadata endpoints such as 169.254.169.254 (AWS, GCP, Azure) and metadata.google.internal, which can hand out temporary credentials
  • Services bound to 127.0.0.1: Redis, admin dashboards, debug ports
  • Private ranges such as 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, plus IPv6 unique-local addresses (fc00::/7)

Any feature that takes a URL from a user and fetches it on the server is exposed. That includes webhooks, where the "user" is whoever configures the integration.

The test: three checks, one payload list#

I compared three approaches:

  • (a) Regex on the raw string. Something like /^(https?:\/\/)?(localhost|127\.|10\.|192\.168\.|169\.254\.)/. This is the check I see most often in code review.
  • (b) new URL() plus a string blocklist. Parse the URL first, then compare url.hostname to a list such as localhost, 127.0.0.1, 0.0.0.0, 169.254.169.254, [::1] and a few prefixes.
  • (c) The Snagbox safe-fetch.ts approach. Parse the URL, allow only http: and https:, reject credentials, reject internal names, and check IP literals with Node's net.BlockList. DNS answers and redirects are checked separately (covered below).

Results, run on the box with tsx on Node.js 20.19.2. "allowed" means the check let the URL through:

Payloadnew URL().hostname(a) regex(b) URL+list(c) safe-fetch static
http://127.0.0.1/127.0.0.1blockedblockedblocked
http://2130706433/127.0.0.1allowedblockedblocked
http://0x7f000001/127.0.0.1allowedblockedblocked
http://0177.0.0.1/127.0.0.1allowedblockedblocked
http://127.1/127.0.0.1blockedblockedblocked
http://[::1]/[::1]allowedblockedblocked
http://[::ffff:127.0.0.1]/[::ffff:7f00:1]allowedallowedblocked
http://[::ffff:7f00:1]/[::ffff:7f00:1]allowedallowedblocked
http://169.254.169.254/latest/meta-data/169.254.169.254blockedblockedblocked
http://[fd00::1]/[fd00::1]allowedallowedblocked
http://0.0.0.0/0.0.0.0allowedblockedblocked
http://LOCALHOST./localhost.blockedallowedblocked
http://metadata.google.internal/metadata.google.internalallowedallowedblocked
http://user:pw@example.com/example.comallowedallowedblocked
file:///etc/passwd``allowedallowedblocked
http://localtest.me/localtest.meallowedallowedallowed

Three things stand out.

1. The WHATWG URL parser already normalizes most IPv4 tricks. new URL("http://2130706433/").hostname is 127.0.0.1. So are 0x7f000001, 0177.0.0.1 and 127.1. That is why approach (b) catches them and the raw-string regex in (a) does not. If you only take one thing from this post: always parse first, then check url.hostname, never the raw input.

2. IPv6 is where string lists fall apart. The parser turns [::ffff:127.0.0.1] into [::ffff:7f00:1], an IPv4-mapped IPv6 address that still points at loopback. No sane string list contains that form. fd00::1 (unique-local) also slips through. net.BlockList handles both: in my tests it blocked ::ffff:a9fe:a9fe (the hex-mapped form of 169.254.169.254) against the IPv4 169.254.0.0/16 rule.

3. A hostname check alone cannot see DNS. localtest.me is a public name that resolves to 127.0.0.1 on the public internet. Every static check, including (c), lets it through, because nothing about the string says "loopback". That is expected. The DNS answer has to be checked at connect time, which is the next section.

Checking IP addresses with net.BlockList#

Node.js has had net.BlockList since v15. It does subnet matching for IPv4 and IPv6, so you don't have to write CIDR math yourself:

ts
import { BlockList, isIP } from "net";

const blocked = new BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "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.addRange("224.0.0.0", "255.255.255.255", "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
blocked.addSubnet("ff00::", 8, "ipv6");

export function isBlockedIp(address: string): boolean {
  let ip = address.trim().toLowerCase().replace(/^\[|\]$/g, "");
  const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(ip);
  if (mapped) ip = mapped[1];
  const family = isIP(ip);
  if (family === 0) return true; // not an IP at all: fail closed
  return blocked.check(ip, family === 6 ? "ipv6" : "ipv4");
}

Note the last two lines: anything that isn't a valid IP is treated as blocked. Fail closed.

The URL-level check wraps this:

ts
export function assertPublicHttpUrl(raw: string): URL {
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    throw new SsrfError("The URL is not a valid http(s) address.");
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") {
    throw new SsrfError("Only http and https URLs can be fetched.");
  }
  if (url.username || url.password) {
    throw new SsrfError("URLs with embedded credentials are not allowed.");
  }
  if (isBlockedHostname(url.hostname)) {
    throw new SsrfError("Refusing to connect to a non-public address.");
  }
  return url;
}

isBlockedHostname strips a trailing dot (so LOCALHOST. doesn't sneak past, which it did in approach (b)), rejects localhost, *.localhost, *.local and *.internal, and runs IP literals through isBlockedIp.

Check every redirect hop#

This is the bug that bites people who did everything else right. You validate the URL, then call fetch(), and fetch() follows redirects on its own.

I tested it with two local servers: a "front" one that answers 302 Location: http://127.0.0.1:<port>/, and an "internal" one that returns internal-secret. Plain fetch(frontUrl) came back with the body internal-secret. The redirect was followed with no check at all.

With fetch(frontUrl, { redirect: "manual" }) you get the 302 and its Location header back instead, and you decide what to do. In Snagbox this is a loop that re-validates each hop:

ts
for (let hop = 0; ; hop++) {
  const response = await transport(current, { ...options, headers: hopHeaders });
  const isRedirect =
    response.status >= 300 && response.status < 400 && response.headers.location;
  if (!isRedirect) return wrap(response, current);

  response.body.resume(); // drain the redirect body
  if (hop >= maxRedirects) throw new SsrfError("Too many redirects.");
  const next = assertPublicHttpUrl(
    new URL(String(response.headers.location), current).toString(),
  );
  hopHeaders = headersForHop(current, next, hopHeaders); // drop auth/cookies cross-host
  current = next;
}

In the test, a fake public host short.example answering 302 to http://169.254.169.254/latest/meta-data/ was refused with SsrfError: Refusing to connect to a non-public address. The metadata request was never made.

This matters a lot for a downloader, because users paste short links (t.co, fb.watch, vm.tiktok.com) all the time. A public short link that bounces to an internal address is the textbook bypass.

Two extra details in that loop are worth copying:

  • Resolve relative Location headers against the current URL with new URL(location, current).
  • Drop Authorization, Cookie and Proxy-Authorization when a redirect changes host, so you don't leak credentials to whoever the redirect points at.

DNS rebinding: check the address you actually connect to#

The static check passed localtest.me. To close that, you check the DNS answer, and you do it at connect time, inside the same request that opens the socket.

Why at connect time? If you resolve the name, check it, then call fetch(url), the HTTP client resolves the name again. An attacker who controls the DNS server can answer with a public IP the first time and 127.0.0.1 the second time, with a TTL of zero. That's DNS rebinding.

Node's http.request and https.request take a lookup option. Whatever that function returns is the address the socket connects to, so checking inside it closes the gap:

ts
import { lookup as dnsLookup, type LookupAddress } from "dns";

function safeLookup(hostname, options, callback) {
  dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
    if (err) return callback(err, "");
    const list = addresses as LookupAddress[];
    if (list.length === 0 || list.some((a) => isBlockedIp(a.address))) {
      const e = new SsrfError(`Refusing to connect to a non-public address for ${hostname}.`);
      (e as NodeJS.ErrnoException).code = "ESSRF";
      return callback(e, "");
    }
    if ((options as { all?: boolean }).all) return callback(null, list);
    callback(null, list[0].address, list[0].family);
  });
}

https.request(url, { lookup: safeLookup, timeout: 30_000 }, onResponse);

Two choices here are deliberate:

  • all: true and reject if any address is private. A name that returns one public and one private address is refused. In my test, ["93.184.216.34", "127.0.0.1"] was blocked, and ["::ffff:169.254.169.254"] was blocked too.
  • The check runs on the exact address used for the connection, so there is no second lookup to race.

If you use undici or the global fetch() instead, the same idea applies through a custom Agent with a connect option that does its own lookup. Checking once before fetch() is not enough.

Gaps I found in my own blocklist#

Running the payloads also showed what my list misses. These all came back "allowed" from isBlockedIp:

  • 100.64.0.1: carrier-grade NAT (100.64.0.0/10), used inside some cloud and Kubernetes networks
  • 198.18.0.1: the benchmarking range (198.18.0.0/15), which some sandboxes and proxies use for internal addresses. My test box's resolver returned this range for localtest.me.
  • 192.0.0.1: IETF protocol assignments (192.0.0.0/24)
  • 64:ff9b::7f00:1: the NAT64 prefix (64:ff9b::/96) wrapping 127.0.0.1
  • fec0::1: deprecated IPv6 site-local (fec0::/10)

None of these are the common attack targets, but on some networks they lead somewhere internal. The fix is a few more lines:

ts
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("192.0.0.0", 24, "ipv4");
blocked.addSubnet("198.18.0.0", 15, "ipv4");
blocked.addSubnet("64:ff9b::", 96, "ipv6");
blocked.addSubnet("fec0::", 10, "ipv6");

The general lesson: start from "only globally routable unicast is allowed", not from a short list of famous bad ranges.

What it costs#

Not much. On my box, isBlockedIp took about 1.8 µs per call, averaged over 100,000 calls (mixed public and mapped-private input). The DNS lookup itself costs whatever your resolver costs, and you were doing that lookup anyway. Moving it into the lookup hook doesn't add a second one.

Tests to keep in CI#

Turn the payload list into a test file so a refactor can't quietly undo this. With the built-in runner:

ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { assertPublicHttpUrl, isBlockedIp } from "./safe-fetch";

const mustBlock = [
  "http://127.0.0.1/", "http://2130706433/", "http://0x7f000001/",
  "http://0177.0.0.1/", "http://[::1]/", "http://[::ffff:127.0.0.1]/",
  "http://169.254.169.254/", "http://[fd00::1]/", "http://LOCALHOST./",
  "http://metadata.google.internal/", "http://user:pw@example.com/",
  "file:///etc/passwd",
];

for (const url of mustBlock) {
  test(`blocks ${url}`, () => assert.throws(() => assertPublicHttpUrl(url)));
}

test("blocks mapped metadata IP", () => assert.ok(isBlockedIp("::ffff:169.254.169.254")));
test("allows a public IP", () => assert.ok(!isBlockedIp("93.184.216.34")));

Add one test for the redirect loop with a fake transport (a host that answers 302 to 169.254.169.254), and one for the lookup hook with a fake DNS answer. Those two are the ones that catch real regressions.

Common mistakes#

  • Running a regex on the raw input instead of on new URL(input).hostname
  • Calling fetch() with the default redirect: "follow" after validating only the first URL
  • Resolving DNS, checking it, then letting the HTTP client resolve again
  • Forgetting IPv6, especially IPv4-mapped forms like ::ffff:7f00:1
  • Allowing file:, gopher: or other schemes because "we only expect https"
  • Treating "not an IP" as safe instead of failing closed
  • Forwarding Authorization or cookies to a redirect target on another host

Key takeaways#

  • Parse with new URL() first. It normalizes decimal, hex and octal IPv4 for you.
  • Use net.BlockList for IP checks. It handles CIDR and IPv4-mapped IPv6.
  • Follow redirects yourself and re-validate every hop.
  • Check the DNS answer inside the lookup hook, so the checked address is the connected address.
  • Keep a payload test in CI, and allow only globally routable addresses rather than blocking a few famous ranges.

If you're building this into a NestJS API, the same safe-fetch module drops into a service. My secure NestJS + Prisma API starter covers the rest of the hardening, the MCP security post covers the auth side of tool servers, and the NestJS Server-Sent Events guide shows how Snagbox streams job progress after the URL passes these checks.

Questions