Software Securityhome
Fix a findingSearch
Fix card · A01:2025 Broken Access Control

Fix SSRF in Node.js and Express

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Attach the policy to the connection, not the hostname string. Allow only http/https and ports 80/443, reject credentials; resolve the host (or read an IP literal in any notation) and reject loopback, private, link-local and IPv4-mapped addresses; connect to the vetted IP so DNS cannot change under you; re-validate every redirect; cap time and bytes.

Stack
  • Language JavaScript (ES modules)
  • Runtime Node.js 26.11.1
  • Framework Express 5.3.0
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 31 KB
On this page

Steps

  1. Find every server-side fetch built from request data (req.query, req.params, req.body): fetch(), got/axios, and node:http/https request().
  2. Parse the URL once with the WHATWG URL. Enforce a scheme allowlist (http/https) and a port allowlist (80/443) and reject userinfo (the https://trusted@internal trick).
  3. Resolve the host with dns.lookup({ all: true }), or recognise an IP literal, and reject every address that is not globally routable (loopback, 10/8, 172.16/12, 192.168/16, 169.254/16, fc00::/7, fe80::/10, IPv4-mapped and NAT64). Reject the whole set if any answer is internal.
  4. Connect to the vetted IP via http.request({ createConnection }) and keep the original host only in the Host header, so the client never re-resolves (no DNS rebinding).
  5. Handle redirects yourself: node:http does not follow them, so re-run the full policy on each Location and keep a hop limit.
  6. Set a connect/total deadline and abort the response once it passes a byte cap; log the parsed destination and a reason server-side, never to the caller.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableimpl/vulnerable.jsJavaScript
// No validation at all: parse the URL and hand the host straight to the resolver.
function target(rawUrl) {
  const url = new URL(rawUrl);
  return {
    hostname: url.hostname.replace(/^\[|\]$/g, ''),
    port: url.port ? Number(url.port) : defaultPort(url.protocol),
    hostHeader: url.port ? `${url.hostname}:${url.port}` : url.hostname,
    pathname: `${url.pathname}${url.search}`,
    origin: url.origin,
  };
}

With IMPL=vulnerable, 12 of 15 tests fail (10 exploit tests).

The fix: validate the destination

Fixedimpl/fixed.jsJavaScript
// Private, loopback, link-local and other non-public ranges. Everything the feature
// should never reach, in one table, so the policy is reviewable in one place.
const DENY_V4 = [
  ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
  ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
  ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
  ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
];

function v4ToBytes(ip) {
  return ip.split('.').map(Number);
}

function v6ToBytes(ip) {
  let text = ip.split('%')[0];
  const embedded = text.match(/(.*:)((?:\d{1,3}\.){3}\d{1,3})$/);
  if (embedded) {
    const [a, b, c, d] = v4ToBytes(embedded[2]);
    text = `${embedded[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
  }
  const halves = text.split('::');
  if (halves.length > 2) return null;
  const head = halves[0] ? halves[0].split(':') : [];
  const tail = halves.length === 2 ? (halves[1] ? halves[1].split(':') : []) : null;
  const groups = tail === null ? head : [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail];
  if (groups.length !== 8) return null;
  const bytes = [];
  for (const g of groups) {
    const v = parseInt(g, 16);
    bytes.push((v >> 8) & 0xff, v & 0xff);
  }
  return bytes;
}

function inRange(bytes, prefix, len) {
  for (let bit = 0; bit < len; bit += 1) {
    const byte = bit >> 3;
    const mask = 1 << (7 - (bit & 7));
    if ((bytes[byte] & mask) !== (prefix[byte] & mask)) return false;
  }
  return true;
}

// inet_aton-style parse so decimal (2130706433), octal (0177.0.0.1) and hex
// (0x7f.0.0.1) literals are recognised as addresses rather than treated as names and
// trusted to the resolver.
function parseIpLiteral(host) {
  const inner = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
  if (net.isIPv6(inner)) return { family: 6, text: inner };
  if (inner !== host) return null; // bracketed but not IPv6
  const parts = host.split('.');
  if (parts.length > 4) return null;
  const nums = [];
  for (const part of parts) {
    let value;
    if (/^0[xX][0-9a-fA-F]+$/.test(part)) value = parseInt(part, 16);
    else if (/^0[0-7]*$/.test(part)) value = parseInt(part || '0', 8);
    else if (/^[1-9][0-9]*$/.test(part)) value = Number(part);
    else return null;
    if (!Number.isSafeInteger(value) || value < 0) return null;
    nums.push(value);
  }
  const caps = [0xffffffff, 0xffffff, 0xffff, 0xff];
  const last = nums.length - 1;
  if (nums[last] > caps[last]) return null;
  if (nums.slice(0, last).some((n) => n > 0xff)) return null;
  const shifts = { 1: [0], 2: [24, 0], 3: [24, 16, 0], 4: [24, 16, 8, 0] }[nums.length];
  const value = nums.reduce((acc, n, i) => acc + n * 2 ** shifts[i], 0);
  const octet = (shift) => Math.floor(value / 2 ** shift) % 256;
  return { family: 4, text: `${octet(24)}.${octet(16)}.${octet(8)}.${octet(0)}` };
}

function assertPublicAddress(ip) {
  let bytes = v4ToBytes(ip);
  let family = 4;
  if (net.isIPv6(ip)) {
    bytes = v6ToBytes(ip);
    family = 6;
    if (!bytes) throw new BlockedRequestError('unparseable address');
    // Unwrap IPv4-mapped (::ffff:a.b.c.d), NAT64 (64:ff9b::/96) and 6to4 (2002::/16).
    if (inRange(bytes, v6ToBytes('::ffff:0:0'), 96) || inRange(bytes, v6ToBytes('64:ff9b::'), 96)) {
      bytes = bytes.slice(12);
      family = 4;
    } else if (inRange(bytes, v6ToBytes('2002::'), 16)) {
      bytes = bytes.slice(2, 6);
      family = 4;
    }
  }
  if (family === 6) {
    const isGlobalUnicast = inRange(bytes, v6ToBytes('2000::'), 3) && !inRange(bytes, v6ToBytes('2001:db8::'), 32);
    if (!isGlobalUnicast) throw new BlockedRequestError('non-public IPv6 address');
    return;
  }
  for (const [prefix, len] of DENY_V4) {
    if (inRange(bytes, v4ToBytes(prefix), len)) throw new BlockedRequestError('non-public IPv4 address');
  }
}

// Parse the URL, enforce the scheme and port allowlists, turn the host into a vetted
// address, and return everything the connection needs. Reject (not resolve) IP
// literals that are not public, so rebinding cannot reach them later.
async function vet(rawUrl, resolve) {
  let url;
  try {
    url = new URL(rawUrl);
  } catch {
    throw new BlockedRequestError('invalid URL');
  }
  if (!DEFAULTS.allowedSchemes.includes(url.protocol)) throw new BlockedRequestError('scheme not allowed');
  if (url.username || url.password) throw new BlockedRequestError('credentials in URL not allowed');
  const port = url.port ? Number(url.port) : defaultPort(url.protocol);
  if (!DEFAULTS.allowedPorts.includes(port)) throw new BlockedRequestError('port not allowed');

  const hostname = url.hostname.replace(/^\[|\]$/g, '');
  const literal = parseIpLiteral(url.hostname);
  const addresses = literal ? [literal.text] : await resolve(hostname);
  if (addresses.length === 0) throw new BlockedRequestError('host did not resolve');
  for (const ip of addresses) assertPublicAddress(ip); // reject the whole set if any is internal

  return {
    ip: addresses[0],
    port,
    hostHeader: url.port ? `${url.hostname}:${url.port}` : url.hostname,
    pathname: `${url.pathname}${url.search}`,
    origin: url.origin,
  };
}

With IMPL=fixed, 15 of 15 tests pass.

The fix: re-validate every redirect

Fixedimpl/fixed.jsJavaScript
// A 3xx response is a fresh destination decision, not permission to follow. Re-run the
// full policy on every hop and pin each connection to its own vetted address.
export async function fetchUrl(rawUrl, deps) {
  const resolve = deps.resolve;
  const maxRedirects = deps.maxRedirects ?? DEFAULTS.maxRedirects;
  let target = rawUrl;
  for (let hop = 0; hop <= maxRedirects; hop += 1) {
    const vetted = await vet(target, resolve);
    const result = await sendOnce(vetted.ip, vetted.port, vetted.hostHeader, vetted.pathname, deps);
    if (result.status >= 300 && result.status < 400 && result.location) {
      target = new URL(result.location, target).href; // relative to this hop's URL; validated again at the top of the loop
      continue;
    }
    return summarise(result);
  }
  throw new BlockedRequestError('too many redirects');
}

With IMPL=fixed, 15 of 15 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.jsJavaScript
// A substring blocklist of "obviously internal" hosts. Looks reassuring; the address
// an attacker actually reaches is decided by DNS, which this never consults.
const BLOCKED_SUBSTRINGS = [
  'localhost', '127.0.0.1', '0.0.0.0', '169.254', '10.', '192.168', '172.16', '::1', 'metadata', 'internal',
];

function target(rawUrl) {
  const url = new URL(rawUrl);
  const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
  if (BLOCKED_SUBSTRINGS.some((bad) => host.includes(bad))) {
    throw new BlockedRequestError('host is blocked');
  }
  return {
    hostname: host,
    port: url.port ? Number(url.port) : defaultPort(url.protocol),
    hostHeader: url.port ? `${url.hostname}:${url.port}` : url.hostname,
    pathname: `${url.pathname}${url.search}`,
    origin: url.origin,
  };
}

With IMPL=wrong, 5 of 15 tests fail (3 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/fetch.test.jsJavaScript
// Each payload names an internal destination a different way. The secure outcome is the
// same every time: the API returns 400 and the internal service is never dialed.
describe(`exploit (IMPL=${implName})`, () => {
  test('exploit: loopback by IPv4 literal', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://127.0.0.1/latest/meta-data/'), fixture);
    });
  });

  test('exploit: loopback as a decimal integer', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://2130706433/'), fixture);
    });
  });

  test('exploit: loopback in octal', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://0177.0.0.1/'), fixture);
    });
  });

  test('exploit: loopback in hex', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://0x7f.0.0.1/'), fixture);
    });
  });

  test('exploit: IPv6 loopback literal', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://[::1]/'), fixture);
    });
  });

  test('exploit: IPv4-mapped IPv6 loopback', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://[::ffff:127.0.0.1]/'), fixture);
    });
  });

  test('exploit: cloud metadata address by hostname', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://metadata.internal.example/latest/meta-data/'), fixture);
    });
  });

  test('exploit: private address by hostname', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://db.internal.example/'), fixture);
    });
  });

  test('exploit: an innocent-looking name that resolves to loopback', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://rebind.example/'), fixture);
    });
  });

  test('exploit: a redirect from an allowed host to an internal one', async () => {
    await withApp(async ({ fetchThrough, fixture }) => {
      assertBlocked(await fetchThrough('http://files.partner.example.test/redirect-internal'), fixture);
    });
  });
});

Pitfalls

  • The WHATWG URL parser normalises 2130706433, 0177.0.0.1 and 0x7f.0.0.1 to 127.0.0.1, so a substring blocklist appears to catch them on Node but misses a name that resolves internally and an IPv4-mapped literal.
  • dns.lookup defaults to one address; pass { all: true } and vet every answer, and set verbatim so IPv6 answers are not hidden.
  • A bare http.request({ host }) re-resolves the name at connect time; validating then connecting by name reopens the rebinding window. Pin the IP.
  • fetch()/undici follows redirects by default (redirect: 'follow'); set redirect: 'manual' or re-validate each hop, and never forward Authorization across origins.
  • IPv4-mapped IPv6 (::ffff:127.0.0.1) and NAT64 (64:ff9b::/96) embed an IPv4 address; unwrap before classifying or loopback slips through as 'IPv6'.
  • A permitted host can still stream forever; without a byte cap and deadline the fetcher is a denial-of-service and memory risk.
  • Blocking SSRF in code does not remove the need for egress control; put a network policy or proxy behind it and keep cloud credentials off the fetcher.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs node-express does this for you).
  2. Each exploit asserts the request is refused with 400 and the internal test server records zero connection attempts, for 127.0.0.1, decimal/octal/hex, [::1], [::ffff:127.0.0.1], a name resolving internal, and a redirect to an internal host.
  3. Against your own endpoint, request http://169.254.169.254/latest/meta-data/ and expect a refusal, and confirm a 302 to an internal host is not followed.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix SSRF in Node.js and Express
ToolRuleWhat it found here
Semgrepjavascript.express.security.audit.express-ssrf.express-ssrfNo findings on vulnerable.js, fixed.js or wrong.js. The rule is file-local taint from req/request to a request sink; here the URL is read in app.js and fetched in impl/*.js over node:http, so the source and the sink are never in one file.Checked: Semgrep 1.180.0 (CE); semgrep --config r/javascript.express.security.audit.express-ssrf.express-ssrf impl (Ran 1 rule)
CodeQLjs/request-forgeryThe JavaScript SSRF models cover http/https/request/axios/got sinks and remote-flow sources. This card builds the request from an injected resolver and dialer and pins the connection to a vetted IP, so a tracked source-to-sink path is unlikely; the regression tests are the control.Checked: github/codeql main; not executed
noten/aNo first-party SSRF linter for node:httpnode:http/node:net have no dedicated SSRF rule in Semgrep CE or a shipped ESLint security plugin, and allowlist logic is invisible to taint analysis. The three-mode regression suite is the enforcement evidence.

Libraries and versions

Libraries and versions
ComponentVersion
LanguageJavaScript (ES modules)
RuntimeNode.js 26.11.1
FrameworkExpress 5.3.0
express5.3.0
node:http, node:net, node:dns (built in)Node.js 26.11.1

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/node-expressfolder.

Shell
npm ci
Shell
# Expected: every test passes
IMPL=fixed npm test --silent

# Expected: an exploit test fails
IMPL=vulnerable npm test --silent

# Expected: a test fails
IMPL=wrong npm test --silent

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs ssrf/node-express runs all three modes and checks the expected results.

Download the project ZIP, 31 KB

Verified

Machine
macOS 27.0, Apple M5 Max
Toolchain
  • node 26.11.1
  • npm 11.20.0
  • express 5.3.0
Matrix
fixed: 15 of 15 tests pass; vulnerable: 12 of 15 tests fail (10 exploit tests); wrong: 5 of 15 tests fail (3 exploit tests)
Test results
fixed: 15/15 pass; vulnerable: 12 fail (10 exploit); wrong: 5 fail (3 exploit)
Notes
Express 5.3.0 was published 2026-10-09 as a security release (qs, body-parser, proxy-addr); taken under the 14-day rule's security exception. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines. Versions and results are true for this date and machine.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Server-Side Request Forgery Prevention Cheat Sheet
  2. Node.js v26 API: HTTP http.request options (createConnection, lookup)
  3. Node.js v26 API: dns.lookup (all, verbatim)
  4. WHATWG URL Standard: host parsing (IPv4 number forms)
  5. Express 5: req.query and the default query parser
  6. CWE-918: Server-Side Request Forgery (SSRF)
  7. RFC 3927: IPv4 Link-Local Addresses (169.254.0.0/16)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.