Safeguard
Security Guides

Preventing SSRF in Node.js Applications

Server-Side Request Forgery is the bug that turns a harmless URL field into a doorway to your cloud metadata service. Here is how SSRF works in Node.js and how to shut it down with allowlists and DNS-safe validation.

Priya Mehta
Security Researcher
6 min read

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.com can resolve to 127.0.0.1. Your string check passes; the socket still lands on loopback.
  • Redirects: you validate the initial URL, but the server responds 302 to 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

LayerControl
CloudEnforce IMDSv2 (session-token required); block IMDS with hop-limit 1
NetworkEgress firewall: the app can only reach the hosts it needs
RuntimeNode --permission with narrow --allow-net scope
Protocolhttps-only; reject file:, gopher:, ftp:, dict: schemes
RedirectsManual 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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.