SSRF protection for a fetch endpoint in Node.js

Published · 6 min read

If your server fetches a URL that someone else chose, for a webhook, a link preview, an image import or an API proxy, that person can point it at your own network. The cloud metadata service at 169.254.169.254, a Redis on localhost, an admin page on 10.0.0.5: your server can reach them, and now so can they. That is server-side request forgery (SSRF).

Checking the URL string is not enough. A name can resolve to any address, change its answer between your check and your request, or redirect somewhere else. This post takes each way around a naive check in turn, then puts the defences together in one Node example. It follows what Dotpane does for every request a pane makes.

Start with the URL

Parse the URL once, check the parsed result, and use that same object for the request. Then refuse anything you don’t need:

  • Only https:. No http:, file:, gopher: or anything else.
  • Only the default port. A port is how a fetcher becomes a port scanner, and how it reaches Redis on 6379.
  • No user name or password in the URL.
  • No IP addresses, only names. The URL parser has already turned https://2130706433/ and https://0x7f.1/ into 127.0.0.1, so check the parsed hostname, not the input.
  • No internal names: localhost, single-label names, and suffixes such as .internal and .local.

Dotpane also refuses a URL that contains tabs, newlines or backslashes before parsing it. The WHATWG parser silently removes the first two and reads \ as /, so the host you check could differ from the host the person wrote.

If you can, go further and allow only a list of hosts. A pane in Dotpane can only call the hosts its manifest lists.

Block private ranges by address, not by name

A name proves nothing. Anyone can create internal.attacker.example with an A record of 127.0.0.1. Resolve the name, get every address (A and AAAA), and refuse if any of them is not public. Checking only the first one is not enough, because the connection may use another.

These are the IPv4 ranges to refuse:

RangeWhat it is
127.0.0.0/8, 0.0.0.0/8Loopback, and “this host”, which also reaches local services.
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Private networks.
169.254.0.0/16Link-local, including the cloud metadata service.
100.64.0.0/10Carrier-grade NAT, often used inside hosting platforms.
224.0.0.0/4, 240.0.0.0/4, 255.255.255.255Multicast, reserved and broadcast.
192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24Protocol assignments, benchmarking and documentation.

Write the check the other way round, too: allow only addresses classified as ordinary public unicast, and refuse everything else. A list of bad ranges misses whatever you forgot to put on it. In Node, ipaddr.js does the classification: range() returns 'unicast' only for addresses outside every special range it knows. Dotpane checks an explicit list first and then refuses anything that is not 'unicast'.

DNS rebinding: connect to the address you checked

The obvious version resolves the name, checks the addresses, then calls fetch(url). But fetch resolves the name again. An attacker’s DNS server can answer the first lookup with a public address and a time-to-live of zero, and the second with 127.0.0.1. Your check passed; your request went to localhost.

The fix is to resolve once and make the connection use the address you checked. In Node, the socket takes a lookup function: give it one that always returns the vetted address. The URL still carries the name, so TLS still checks the certificate against that name, and the Host header is still correct.

Node’s built-in fetch does not accept an http.Agent, which is what most SSRF libraries for Node hook into. Use undici directly, with its own Agent and a connect.lookup, as in the example below.

Redirects: don’t follow them

A public URL can answer 302 with Location: http://169.254.169.254/latest/meta-data/. fetch follows redirects by default, to an address you never checked.

Either don’t follow them, or run every check again on every hop, with a limit on hops. Not following is simpler and has fewer ways to go wrong: return the 3xx and its Location to the caller, and let them decide. Dotpane does that. undici’s request never follows redirects; with fetch, set redirect: 'manual'.

IPv6, and the addresses that hide an IPv4 one

IPv6 has several ways to carry an IPv4 address inside it, and some of them still reach the IPv4 host:

  • IPv4-mapped, ::ffff:127.0.0.1. ipaddr.process() turns it back into 127.0.0.1 before you classify it.
  • IPv4-compatible, ::127.0.0.1, an old form. ipaddr.js 2.5 classifies it as 'unicast', so a check that relies on range() alone lets it through. Block ::/96 yourself.
  • NAT64, 64:ff9b::/96. On a network with NAT64, 64:ff9b::a00:1 reaches 10.0.0.1.
  • 6to4 (2002::/16) and Teredo (2001::/32), which also embed IPv4 addresses.

Then the plain IPv6 equivalents of the private ranges: ::1 loopback, fc00::/7 unique-local, fe80::/10 link-local and ff00::/8 multicast. Dotpane refuses every one of these prefixes outright, whatever address is embedded in them. A real public API does not live there.

Remember the AAAA records. A name with a public A record and a private AAAA record passes a check that only looked at IPv4.

Limit time and size

A fetcher without limits holds connections open and fills memory. Set one deadline that covers DNS, connecting and reading the whole body, not just the time to the first byte. Cap the body size, and count it after decompression: a few kilobytes of gzip can expand to gigabytes. Dotpane counts both the compressed and the decompressed stream, gives a call 10 seconds, and stops a response at 2 MB.

When a request fails, return a short, fixed error code, not the underlying error message, and don’t pass on headers that let the caller choose the Host.

Run the fetcher where it can’t reach anything

Every check above is code, and code has bugs. Put the fetching in a service that has nothing to reach if a check fails: no route to your databases, caches, key store or metadata service.

In Dotpane that service is called egress. It runs in a separate hosting project from the rest of the app, with no private-network route to the database, Redis or the key service, and it holds no secrets of its own beyond the one that verifies requests. The api signs each request it sends to egress, and egress repeats every URL and host check instead of trusting the api.

The whole thing in Node

This puts the steps together with undici and ipaddr.js. It takes a URL, refuses what it should, connects only to the address it checked, returns redirects as they are, and stops at a deadline and a size cap.

import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import ipaddr from 'ipaddr.js';
import { Agent, request } from 'undici';

const IPV4_COMPATIBLE = ipaddr.IPv6.parseCIDR('::/96');

function isPublic(ip) {
  const address = ipaddr.process(ip); // ::ffff:10.0.0.1 becomes 10.0.0.1
  if (address.kind() === 'ipv6' && address.match(IPV4_COMPATIBLE)) return false;
  return address.range() === 'unicast';
}

export async function safeFetch(input, { timeoutMs = 10_000, maxBytes = 2_000_000 } = {}) {
  const url = new URL(input);
  if (url.protocol !== 'https:' || url.port !== '' || url.username !== '' || url.password !== '')
    throw new Error('URL not allowed');
  const host = url.hostname;
  if (host.startsWith('[') || isIP(host) !== 0) throw new Error('IP addresses not allowed');

  // Resolve once. Every address must be public.
  const addresses = await lookup(host, { all: true, order: 'verbatim' });
  if (addresses.length === 0 || !addresses.every((a) => isPublic(a.address)))
    throw new Error('Address not allowed');
  const { address, family } = addresses[0];

  // Connect only to the vetted address. TLS still checks the certificate for `host`.
  const dispatcher = new Agent({
    connect: {
      lookup(hostname, options, callback) {
        if (hostname !== host) return callback(new Error('Unexpected lookup'));
        if (options.all) callback(null, [{ address, family }]);
        else callback(null, address, family);
      },
    },
  });

  try {
    // request() does not follow redirects: a 3xx comes back as it is.
    const res = await request(url, { dispatcher, signal: AbortSignal.timeout(timeoutMs) });
    const chunks = [];
    let size = 0;
    for await (const chunk of res.body) {
      size += chunk.length;
      if (size > maxBytes) throw new Error('Response too large');
      chunks.push(chunk);
    }
    return { status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) };
  } finally {
    await dispatcher.close();
  }
}

The body here is not decompressed, so the cap counts what arrived. If you decompress, count again after decompressing. Add your host allowlist after the URL checks, and the internal-name rules from the first section if you accept arbitrary hosts. It is a starting point to adapt, not a library to copy unread.

Keep reading

CORS error on fetch: what causes it and how to fix itWhy the browser blocks the call, and which fix fits which case.How to hide an API key in frontend JavaScriptYou can’t. Anything the browser can read, a visitor can read. Here is what to do instead, with the trade-offs of each option.How Dotpane keeps your API keys out of the browserSecrets are encrypted with AES-256-GCM, panes run in a sandboxed frame on a separate domain, and a key is only ever sent to the hosts you allow.