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.
- Find every server-side fetch built from request data (req.query, req.params, req.body): fetch(), got/axios, and node:http/https request().
- 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).
- 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.
- 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).
- Handle redirects yourself: node:http does not follow them, so re-run the full policy on each Location and keep a hop limit.
- 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.
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
// 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
// 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
// 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
// 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 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.
// 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);
});
});
});- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.express.security.audit.express-ssrf.express-ssrf | No 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) |
| CodeQL | js/request-forgery | The 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 |
| note | n/aNo first-party SSRF linter for node:http | node: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. |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| node:http, node:net, node:dns (built in) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/node-expressfolder.
npm ci# 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 --silentEach 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.
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
- OWASP Server-Side Request Forgery Prevention Cheat Sheet
- Node.js v26 API: HTTP http.request options (createConnection, lookup)
- Node.js v26 API: dns.lookup (all, verbatim)
- WHATWG URL Standard: host parsing (IPv4 number forms)
- Express 5: req.query and the default query parser
- CWE-918: Server-Side Request Forgery (SSRF)
- RFC 3927: IPv4 Link-Local Addresses (169.254.0.0/16)
Attack patterns: