Any Node.js endpoint that fetches a URL on the user's behalf — a webhook tester, an image proxy, a "import from URL" feature, a link preview generator — is a potential Server-Side Request Forgery vulnerability. SSRF sits on the OWASP Top 10 for a reason: it lets an attacker use your trusted server as a launch point to reach places they never could directly, most dangerously your cloud provider's instance metadata service. This guide shows exactly how SSRF plays out in Node, why the obvious defenses fail, and how to build one that actually holds.
What SSRF actually buys an attacker
The naive version of a URL-fetching feature looks like this:
app.post("/preview", async (req, res) => {
const response = await fetch(req.body.url); // attacker controls url
const html = await response.text();
res.json({ title: extractTitle(html) });
});
Nothing stops the attacker from submitting http://169.254.169.254/latest/meta-data/iam/security-credentials/. On a cloud instance without IMDSv2 enforced, that endpoint returns temporary IAM credentials — and now the attacker has your server's cloud permissions. Other high-value targets: http://localhost:6379 (an unauthenticated Redis), internal admin panels bound to private IPs, and Kubernetes service endpoints. Your server can reach all of them; the attacker's browser cannot. SSRF bridges that gap.
Why blocklists don't work
The instinct is to block "bad" hosts — localhost, 127.0.0.1, 169.254.169.254. This fails in more ways than you can enumerate:
- Alternate encodings of localhost:
127.0.0.1,0.0.0.0,0177.0.0.1(octal),2130706433(decimal),[::1],127.1. - DNS resolution:
attacker.comcan resolve to127.0.0.1. Your string check passes; the socket still lands on loopback. - Redirects: you validate the initial URL, but the server responds
302to an internal address and your HTTP client follows it. - DNS rebinding: the hostname resolves to a safe public IP when you validate it, then to an internal IP microseconds later when you connect — a time-of-check/time-of-use race.
Any defense built on inspecting the URL string is defeated by one of these. You have to validate the IP you're actually going to connect to.
The defense that holds: resolve, verify, then pin
The correct pattern is an allowlist of permitted hosts combined with validating the resolved IP address and preventing redirect-based escapes. Resolve the hostname yourself, reject any private/reserved IP, and connect to that verified address.
import dns from "node:dns/promises";
import net from "node:net";
import ipaddr from "ipaddr.js";
const ALLOWED_HOSTS = new Set(["api.partner.com", "images.cdn.example"]);
function isPublicUnicast(ip) {
const addr = ipaddr.parse(ip);
const range = addr.range();
// reject loopback, private, linkLocal (169.254.x), uniqueLocal, reserved
return range === "unicast";
}
async function assertSafeUrl(rawUrl) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") throw new Error("https only");
if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");
const { address } = await dns.lookup(url.hostname);
if (!isPublicUnicast(address)) throw new Error("resolves to private range");
return { url, address };
}
Two more rules make it airtight:
Disable redirect-following (or re-validate every hop). With fetch, set redirect: "manual" and reject anything that isn't a 2xx. With undici, cap maxRedirections: 0.
Pin the connection to the verified IP to close the DNS-rebinding window. Because you already resolved to address, connect to that IP and pass the original hostname for TLS SNI/verification, so a second DNS lookup can't sneak in a different address between check and use.
const { url, address } = await assertSafeUrl(req.body.url);
const res = await fetch(url, {
redirect: "manual",
// route the socket to the validated IP; keep Host header for TLS/vhost
dispatcher: pinnedDispatcher(address, url.hostname),
});
Defense-in-depth beyond the code
| Layer | Control |
|---|---|
| Cloud | Enforce IMDSv2 (session-token required); block IMDS with hop-limit 1 |
| Network | Egress firewall: the app can only reach the hosts it needs |
| Runtime | Node --permission with narrow --allow-net scope |
| Protocol | https-only; reject file:, gopher:, ftp:, dict: schemes |
| Redirects | Manual redirect handling with per-hop re-validation |
The egress firewall is the most underused control: if your app only ever needs to reach two partner APIs, an allowlist at the network layer means SSRF fails even when the application code has a bug.
Watch the features that invite SSRF
SSRF rarely arrives labeled as a security feature — it hides inside product requirements. The riskiest patterns to audit in a Node.js codebase are webhook registration and delivery (you send a POST to a URL the user supplied), "import from URL" flows for avatars, documents, or RSS feeds, link-preview and unfurling services, PDF or screenshot generators that render a user-supplied URL, and any integration that calls back to a customer-configured endpoint. Each of these is a legitimate feature and a potential SSRF sink. Treat the assertSafeUrl pattern above as a shared library that every one of these code paths must call — not a check you reimplement per feature, where one handler inevitably forgets a rule.
Server-side webhooks deserve special care because they run on a schedule or in response to events, often with elevated internal network access and without a human watching. A webhook delivery worker that follows redirects and resolves DNS at send time is a textbook DNS-rebinding target. Apply the same resolve-verify-pin discipline there, and log every blocked destination so you can spot probing.
How Safeguard helps
SSRF is a design flaw that static rules alone struggle to confirm — you have to see whether the sink is actually reachable with attacker-controlled input. Safeguard's dynamic application security testing probes your running application with SSRF payloads, including metadata-endpoint and redirect-based variants, and reports only the flows it can actually reach — so you're not chasing theoretical findings. Software composition analysis flags HTTP-client and URL-parsing libraries with known SSRF-relevant CVEs in your dependency tree, and Griffin AI explains each finding and drafts the allowlist-based fix. Wire it into your pipeline with the Safeguard CLI so a new URL-fetching endpoint gets tested before it merges.
Get started
The core lesson of SSRF: validate the destination you'll actually connect to, not the string a user typed. Build the allowlist above, enforce IMDSv2, and let automated testing confirm you got it right. Sign up free at app.safeguard.sh/register and see the DAST configuration guide at docs.safeguard.sh.