SSRF in Node.js: how a user-supplied URL reaches your cloud
Server-side request forgery (SSRF) in Node.js usually starts with a helpful feature. A link preview, a webhook tester, an "import from URL" button, a PDF renderer that fetches images. The server takes a URL from the user and fetches it. The user picks the URL, but the request leaves from inside your network, with your server's reach.
That reach is the problem. From the outside an attacker cannot talk to your admin service on 10.0.3.7, your Redis on localhost, or the cloud metadata service at 169.254.169.254. Your server can, and an SSRF bug lets the attacker borrow it.
This post walks through the Node.js shapes of SSRF, the fixes that hold up (and the ones that do not), and what Vulkro checks in your code.
What does SSRF in Node.js look like?
The plainest version is one line:
app.post('/api/preview', requireAuth, async (req, res) => {
const r = await fetch(req.body.url); // user picks the destination
res.json({ title: extractTitle(await r.text()) });
});
axios.get(req.query.url), got(target), http.get(url) and needle('get', url) are the same bug. So is a URL that is only partly user-controlled, such as fetch(`http://${req.body.host}/status`): the host is the part that matters.
Three variations make the simple checks fail.
Redirects. You check that the URL points at https://good.example, then fetch it. The server at good.example answers 302 Location: http://169.254.169.254/latest/meta-data/. Node's built-in fetch and axios both follow redirects by default, so your check approved one host and the request landed on another.
DNS rebinding. You resolve attacker.example, see a public address, and allow it. A moment later the HTTP client resolves the name again, and this time the attacker's DNS server answers 127.0.0.1. The check and the connection looked at two different answers.
Address tricks. http://2852039166/, http://0xA9FEA9FE/, http://[::ffff:169.254.169.254]/ and http://169.254.169.254.nip.io/ all reach the metadata address. A string blocklist that looks for 169.254 misses most of them.
Why cloud metadata is the prize
On a cloud VM the metadata service hands out the instance's identity, including temporary credentials for its role. An SSRF that can read it can often read those credentials, and from there anything the role can reach.
That is the shape of the 2019 Capital One breach. The US Department of Justice says the intrusion "occurred through a misconfigured web application firewall that enabled access to the data." Contemporary analysis by KrebsOnSecurity described the technique as SSRF: the firewall was tricked into querying the metadata service, which returned credentials that could read storage buckets.
In November 2019 AWS introduced IMDSv2, which requires a session token obtained with a PUT request, refuses requests carrying an X-Forwarded-For header, and sends the token response with a hop limit of 1. Most SSRF bugs let an attacker choose a URL but not the method or headers, so they cannot complete that exchange. If you run on EC2, require IMDSv2 on every instance. It is the cheapest SSRF control you will ever deploy. It does not protect your other internal services, so the code still needs fixing.
How do you prevent SSRF in Node.js?
The OWASP SSRF Prevention Cheat Sheet orders the defences clearly, and its advice on lists is short: "Deny-lists are bypass-prone. Prefer allow-lists." In order of strength:
1. Do not take a URL at all
Often the user does not need to name a URL. They need to pick one of a few destinations. Take a key and look the URL up on the server:
const STATUS_URLS: Record<string, string> = {
github: 'https://www.githubstatus.com/api/v2/status.json',
stripe: 'https://status.stripe.com/api/v2/status.json',
};
app.get('/api/status/:provider', async (req, res) => {
const p = req.params.provider;
if (!Object.hasOwn(STATUS_URLS, p)) return res.sendStatus(404);
const r = await fetch(STATUS_URLS[p]);
res.json(await r.json());
});
The request chooses which entry, never what the entry says.
2. Allowlist the host, anchored
When the user must supply a URL (a webhook to a known provider, say), parse it and compare the hostname exactly:
const WEBHOOK_HOST = /^https:\/\/hooks\.slack\.com\//;
app.post('/api/webhook-test', requireAuth, async (req, res) => {
const target = String(req.body.url);
if (!WEBHOOK_HOST.test(target)) return res.status(400).send('Unsupported webhook host');
await fetch(target, { method: 'POST', body: '{"text":"test"}', redirect: 'error' });
res.sendStatus(204);
});
The ^ and the trailing / are what make it safe. /hooks\.slack\.com/ without anchors also matches https://hooks.slack.com.attacker.example/.
3. Resolve and check at connect time, and block private ranges
When the destination is genuinely arbitrary (a link preview), there is no allowlist to use. Block every non-public address, and do it on the address the socket actually connects to, so a rebinding DNS answer cannot slip past. With node:http or axios you can put the check in the agent's lookup:
import dns from 'node:dns';
import http from 'node:http';
import https from 'node:https';
import ipaddr from 'ipaddr.js';
import axios from 'axios';
const isPublic = (a: string) => ipaddr.process(a).range() === 'unicast';
function safeLookup(host: string, opts: dns.LookupOptions, cb: (...a: any[]) => void) {
dns.lookup(host, { ...opts, all: true }, (err, addrs) => {
if (err) return cb(err);
const bad = addrs.find((a) => !isPublic(a.address));
if (bad) return cb(new Error(`Blocked non-public address ${bad.address}`));
if (opts.all) return cb(null, addrs);
cb(null, addrs[0].address, addrs[0].family);
});
}
const agentOpts = { lookup: safeLookup as any };
const client = axios.create({
httpAgent: new http.Agent(agentOpts),
httpsAgent: new https.Agent(agentOpts),
maxRedirects: 0, // a redirect is a new, unchecked destination
proxy: false, // a proxy would do its own resolution
timeout: 5000,
maxContentLength: 1_000_000,
});
ipaddr.process turns IPv4-mapped IPv6 addresses back into IPv4 before the range check, and anything that is not ordinary public unicast (loopback, private, link-local, carrier-grade NAT, reserved) is refused. Also refuse any scheme other than http: and https: before the call.
4. Turn off redirects
Set redirect: 'error' (or 'manual') on fetch and maxRedirects: 0 on axios. If you need to follow a redirect, run the full check again on the new location.
5. Limit what the server can reach
Egress rules, network segmentation and IMDSv2 catch what the code misses. They are the layer that still works when someone adds a second fetch next year.
What doesn't work
isValidUrlornew URL()alone. They tell you the string is a URL.http://169.254.169.254/is a perfectly valid URL.- String blocklists.
url.includes('localhost')misses127.1,[::1], decimal and hex forms, and any DNS name that resolves inward. - Checking once, then fetching. A check on a separate DNS answer leaves the rebinding gap open.
startsWith('https://good.example'). It acceptshttps://good.example.attacker.example.
What Vulkro recognises as an SSRF guard
Vulkro follows request data (body, query and route parameters) into HTTP client calls such as fetch and axios. In JavaScript and TypeScript the rule is js-taint-ssrf-001; Python, Go and Java have their own arms. The finding names the route, each step the value takes and the call it reaches. A literal metadata address in code is reported by itself, whatever feeds it.
A finding is only useful if the fixed code stops producing it. A request-tainted URL is not reported when one of these stands between the request and the call, on every path:
- An SSRF validator whose verdict is tested before the fetch, such as
if (!(await isTrustedInternalUrl(u, BASE))) returnor a testedvalidateUrlForSSRF(u)result. A validator whose result is ignored, or that runs after the call, still leaves the finding. - An anchored host allowlist pattern with an early exit, as in fix 2 above. An unanchored pattern, or one that does not pin the host before the first
/, does not count. - A URL picked from a fixed table by a request key, as in fix 1.
- A URL built from a server-side base with every request value encoded into it.
Two honest limits. First, Vulkro recognises a validator by its name and where it sits, not by reading its body. Format checks such as isValidUrl are deliberately not treated as guards, but a function called validateUrlForSSRF that returns true for everything would clear the finding. Read your validator once. Second, a guard implemented only in network policy or IMDSv2 is invisible to a source scan, so the code finding stays until the code changes.
SSRF checklist for Node.js
- Replace user-supplied URLs with a key into a server-side table wherever you can.
- Where you cannot, allowlist the host with an exact, anchored match.
- For arbitrary URLs, check the resolved address at connect time and refuse non-public ranges.
- Disable redirects, or re-check every hop.
- Allow only
http:andhttps:. - Require IMDSv2, and restrict egress from the services that fetch.
- Scan, and treat a finding that survives your fix as a question about the fix.
Frequently asked questions
Does using axios instead of fetch prevent SSRF?
No. Both follow redirects by default and both will connect to any address the URL resolves to. The protection comes from what you check before and during the connection, not from the client library.
Is IMDSv2 enough to stop SSRF?
It stops the most damaging outcome on AWS, credential theft from the metadata service, for the common case where the attacker controls only the URL. It does nothing for your databases, admin panels or other internal services, so fix the code as well.
Why is DNS rebinding a problem if I already validate the hostname?
Because the name is resolved twice: once by your check and once by the HTTP client. An attacker who controls the DNS answers can return a public address the first time and an internal one the second. Checking inside the connection's own lookup closes that gap.
Can a static scan prove my SSRF fix works?
It can show that request data no longer reaches the call without passing a recognised guard. It cannot prove the guard's logic is correct or see network controls, so pair it with a review of the validator and a test that fetches http://169.254.169.254/ and expects a refusal.
Where to see it
SSRF is API7 in the OWASP API Security Top 10, and it often sits behind the same missing checks we covered in broken access control and Next.js server actions. The API security use case shows how Vulkro traces a route to the call behind it, and the API7 rule page and taint analysis docs have the details. To run it on your own repository, start here.
