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

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:

| Payload | new URL().hostname | (a) regex | (b) URL+list | (c) safe-fetch static |
|---|---|---|---|---|
| `http://127.0.0.1/` | `127.0.0.1` | blocked | blocked | blocked |
| `http://2130706433/` | `127.0.0.1` | **allowed** | blocked | blocked |
| `http://0x7f000001/` | `127.0.0.1` | **allowed** | blocked | blocked |
| `http://0177.0.0.1/` | `127.0.0.1` | **allowed** | blocked | blocked |
| `http://127.1/` | `127.0.0.1` | blocked | blocked | blocked |
| `http://[::1]/` | `[::1]` | **allowed** | blocked | blocked |
| `http://[::ffff:127.0.0.1]/` | `[::ffff:7f00:1]` | **allowed** | **allowed** | blocked |
| `http://[::ffff:7f00:1]/` | `[::ffff:7f00:1]` | **allowed** | **allowed** | blocked |
| `http://169.254.169.254/latest/meta-data/` | `169.254.169.254` | blocked | blocked | blocked |
| `http://[fd00::1]/` | `[fd00::1]` | **allowed** | **allowed** | blocked |
| `http://0.0.0.0/` | `0.0.0.0` | **allowed** | blocked | blocked |
| `http://LOCALHOST./` | `localhost.` | blocked | **allowed** | blocked |
| `http://metadata.google.internal/` | `metadata.google.internal` | **allowed** | **allowed** | blocked |
| `http://user:pw@example.com/` | `example.com` | **allowed** | **allowed** | blocked |
| `file:///etc/passwd` | `` | **allowed** | **allowed** | blocked |
| `http://localtest.me/` | `localtest.me` | **allowed** | **allowed** | **allowed** |

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](/blog/secure-nestjs-prisma-api-starter) covers the rest of the hardening, the [MCP security post](/blog/mcp-security) covers the auth side of tool servers, and the [NestJS Server-Sent Events guide](/blog/nestjs-server-sent-events) shows how Snagbox streams job progress after the URL passes these checks.
