January 13, 2026 · 3 min read
Preventing SSRF in Node.js: Safe Outbound Requests from User Input
Any time your server fetches a URL that a user supplied, you've opened the door to Server-Side Request Forgery (SSRF). It's one of the most under-appreciated vulnerabilities in web apps, and it's easy to introduce by accident: a URL preview generator, a webhook tester, an "import from URL" feature. Here's how the attack works and how to shut it down in Node.
The attack
Your server runs inside a network the attacker can't reach directly - a private VPC, a Kubernetes cluster, a cloud VM. When you fetch a user-supplied URL from that server, the attacker borrows your server's network position. They point your fetcher at:
http://169.254.169.254/- the cloud metadata endpoint, which can leak credentials.http://localhost:6379/- an internal Redis or admin service.http://10.0.0.5/- another box on your private network.
Your firewall never sees an external attacker. It sees your own trusted server making the request.
The core defense: resolve, then check
Blocking hostnames like localhost isn't enough - 127.0.0.1, 0177.0.0.1, and a domain whose DNS record points at 10.0.0.5 all reach internal targets. The reliable approach is to resolve the hostname to IP addresses and reject any that are private or reserved:
import dns from 'node:dns/promises';
import net from 'node:net';
async function assertSafeUrl(raw: string) {
const u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad scheme');
const addrs = net.isIP(u.hostname)
? [{ address: u.hostname }]
: await dns.lookup(u.hostname, { all: true });
for (const { address } of addrs) {
if (isPrivate(address)) throw new Error('refusing private address');
}
return u;
}
isPrivate should reject the full set of non-public ranges: loopback (127.0.0.0/8, ::1), link-local (169.254.0.0/16 - this covers the metadata endpoint), private ranges (10/8, 172.16/12, 192.168/16), unique-local IPv6 (fc00::/7), and IPv4-mapped IPv6 addresses.
Re-check every redirect
Here's the subtlety that catches people: a public URL can respond with a 302 redirect to an internal one. If you follow redirects automatically, your validation on the first URL is worthless. Follow redirects manually and validate each hop:
let current = (await assertSafeUrl(input)).toString();
for (let i = 0; i <= 5; i++) {
await assertSafeUrl(current); // re-check every hop
const res = await fetch(current, { redirect: 'manual' });
const loc = res.headers.get('location');
if (res.status >= 300 && res.status < 400 && loc) {
current = new URL(loc, current).toString();
continue;
}
return res;
}
Belt and suspenders
Also: always set a timeout (AbortSignal.timeout(8000)), cap the response size, and rate-limit the endpoint. And if you can, run the fetcher with no access to internal networks at all - defense in depth means not relying solely on the IP check.
Why bother
I use exactly this guard in a public "endpoint inspector" tool that makes outbound requests on user input. It's the difference between a helpful utility and an open proxy into my infrastructure. If any part of your app fetches user-controlled URLs, this check belongs there today.
Want a second set of eyes on your app's security? Get in touch.