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 with node:http, not fetch(); re-validate every redirect; cap time and bytes.
- Find every server-side fetch built from request data in route handlers, server actions and server components: fetch(), axios or got, and node:http/https request().
- Read the URL with searchParams.getAll() (exactly one value) or from a validated body, parse it 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, verbatim: 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 nothing re-resolves the name (no DNS rebinding). Keep the route on the Node.js runtime, which node:http needs.
- 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 and total deadline and abort the response once it passes a byte cap; log the destination and the reason on the server and return a generic 400 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
export async function fetchUrl(rawUrl: string, deps: FetchDeps) {
const maxRedirects = deps.maxRedirects ?? DEFAULTS.maxRedirects;
let current = rawUrl;
for (let hop = 0; hop <= maxRedirects; hop += 1) {
const t = target(current);
const addresses = await deps.resolve(t.hostname);
// No size limit: read whatever the destination sends.
const result = await sendOnce(addresses[0], t.port, t.hostHeader, t.pathname, {
...deps,
maxResponseBytes: Number.MAX_SAFE_INTEGER,
});
if (result.status >= 300 && result.status < 400 && result.location) {
current = new URL(result.location, t.origin).href; // follow wherever it points
continue;
}
return summarise(result);
}
return { status: 0, note: 'too many redirects' };
}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: [string, number][] = [
['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: string) {
return ip.split('.').map(Number);
}
function v6ToBytes(ip: string): number[] | null {
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: number[] = [];
for (const g of groups) {
const v = parseInt(g, 16);
bytes.push((v >> 8) & 0xff, v & 0xff);
}
return bytes;
}
const V6 = (ip: string) => v6ToBytes(ip) as number[];
function inRange(bytes: number[], prefix: number[], len: number) {
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: string): { family: 4 | 6; text: string } | null {
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: number[] = [];
for (const part of parts) {
let value: number;
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] } as Record<number, number[]>)[nums.length];
const value = nums.reduce((acc, n, i) => acc + n * 2 ** shifts[i], 0);
const octet = (shift: number) => Math.floor(value / 2 ** shift) % 256;
return { family: 4, text: `${octet(24)}.${octet(16)}.${octet(8)}.${octet(0)}` };
}
function assertPublicAddress(ip: string) {
let bytes: number[] | null = 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, V6('::ffff:0:0'), 96) || inRange(bytes, V6('64:ff9b::'), 96)) {
bytes = bytes.slice(12);
family = 4;
} else if (inRange(bytes, V6('2002::'), 16)) {
bytes = bytes.slice(2, 6);
family = 4;
}
}
if (family === 6) {
const isGlobalUnicast = inRange(bytes, V6('2000::'), 3) && !inRange(bytes, V6('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: string, resolve: Resolve) {
let url: 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: string, deps: FetchDeps) {
const maxRedirects = deps.maxRedirects ?? DEFAULTS.maxRedirects;
let target = rawUrl;
for (let hop = 0; hop <= maxRedirects; hop += 1) {
const vetted = await vet(target, deps.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',
];
type Target = { hostname: string; port: number; hostHeader: string; pathname: string; origin: string };
function toTarget(url: URL): Target {
return {
hostname: url.hostname.replace(/^\[|\]$/g, '').toLowerCase(),
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,
};
}
function screen(rawUrl: string): Target {
const target = toTarget(new URL(rawUrl));
if (BLOCKED_SUBSTRINGS.some((bad) => target.hostname.includes(bad))) {
throw new BlockedRequestError('host is blocked');
}
return target;
}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})`, () => {
const payloads: [string, string][] = [
['loopback by IPv4 literal', 'http://127.0.0.1/latest/meta-data/'],
['loopback as a decimal integer', 'http://2130706433/'],
['loopback in octal', 'http://0177.0.0.1/'],
['loopback in hex', 'http://0x7f.0.0.1/'],
['IPv6 loopback literal', 'http://[::1]/'],
['IPv4-mapped IPv6 loopback', 'http://[::ffff:127.0.0.1]/'],
['cloud metadata address by hostname', 'http://metadata.internal.example/latest/meta-data/'],
['private address by hostname', 'http://db.internal.example/'],
['an innocent-looking name that resolves to loopback', 'http://rebind.example/'],
['a redirect from an allowed host to an internal one', 'http://files.partner.example.test/redirect-internal'],
];
for (const [name, url] of payloads) {
test(`exploit: ${name}`, async () => {
await withApp(async ({ fetchThrough, fixture }) => {
expectBlocked(await fetchThrough(url), fixture);
});
});
}
});- Next.js extends the server's fetch() with caching options (cache, next.revalidate); it adds no destination checks. fetch() follows redirects by default (redirect: 'follow') and resolves the host itself, so validating the URL and then calling fetch(url) leaves both the redirect and the DNS-rebinding gap open. If you do use fetch(), pass redirect: 'manual' and re-check every Location, and accept that the address is not pinned.
- 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 but misses a name that resolves internally and an IPv4-mapped literal, which the parser writes as [::ffff:7f00:1].
- dns.lookup returns one address unless you pass { all: true }; vet every answer.
- A route.ts file may only export route handlers and route segment options, so keep factories and helpers in lib/ and export the bound handler.
- Server components and server actions run the same server-side code: a fetch(userUrl) in a page or an action is the same SSRF as one in a route handler.
- Blocking SSRF in code does not remove the need for egress control; put a network policy or proxy behind it and keep cloud credentials out of the server's environment.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs ssrf/typescript-nextjs does this for you).
- Each exploit calls the GET /fetch handler with a Request and 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], names resolving internal, and a redirect to an internal host.
- Against your own deployment, request /fetch?url=http%3A%2F%2F169.254.169.254%2Flatest%2Fmeta-data%2F 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 any implementation. The rule's sources are Express req objects, which a route handler's Request is not, and the URL is read in lib/fetch-handler.ts but fetched in lib/impl/*.ts over node:http.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/javascript.express.security.audit.express-ssrf.express-ssrf app lib (Ran 1 rule) |
| Semgrep | p/default, p/javascript, p/typescript, p/security-audit, p/owasp-top-ten | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); on app and lib |
| CodeQL | js/request-forgery | CodeQL's Next.js model (Next.qll) treats a function exported as GET from app/**/route.ts as a route handler and request.url as a remote source. Here GET is the return value of a factory in lib/fetch-handler.ts and the request goes out through an injected dialer and http.request with createConnection, so whether a path is reported was not tested; the regression tests are the control.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | TypeScript |
| Runtime | Node.js 26.11.1 (route handlers use the default Node.js runtime; node:http, node:net and node:dns are not available on the Edge runtime) |
| Framework | Next.js 16.4.0 (App Router route handlers) |
| next | 16.4.0 |
| react, react-dom | 19.3.0 |
| node:http, node:net, node:dns (built in) | Node.js 26.11.1 |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the ssrf/typescript-nextjsfolder.
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/typescript-nextjs runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- next 16.4.0
- react 19.3.0
- vitest 5.0.3 (Vite 8.3.4)
- typescript 6.0.3
- semgrep 1.180.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/15 fail (10 exploit tests); wrong 5/15 fail (3 exploit tests: the IPv4-mapped literal, the name that resolves to loopback and the redirect), offline after npm ci
- Dependency freshness
- next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (TypeScript 7 no longer exports the compiler API next build's type check loads). npm audit found 0 vulnerabilities. tsc --noEmit and next build pass. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
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
- Next.js: fetch (how Next.js extends fetch on the server)
- Next.js: Route Handlers (App Router)
- Fetch Standard: request redirect mode
- 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)
- CWE-918: Server-Side Request Forgery (SSRF)
Attack patterns: