SSRF Guards
Prevent Server-Side Request Forgery - block fetches to internal IPs, cloud metadata endpoints, and private networks when users supply URLs.
Recipe
Quick-reference recipe card - copy-paste ready.
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
const BLOCKED = new Set(["private", "loopback", "linkLocal", "uniqueLocal", "carrierGradeNat"]);
async function assertSafeUrl(raw: string): Promise<URL> {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) throw new Error("Invalid protocol");
const { address } = await lookup(url.hostname);
const range = ipaddr.parse(address).range();
if (BLOCKED.has(range)) throw new Error("Blocked destination");
return url;
}When to reach for this:
- Webhooks, "import from URL", PDF renderers, or image proxies accept user URLs.
- Integrations fetch partner APIs from user-configured endpoints.
- Penetration test flags cloud metadata access.
- Any
fetch(userSuppliedUrl)in the codebase.
Working Example
import { lookup } from "node:dns/promises";
import ipaddr from "ipaddr.js";
const METADATA_HOSTS = new Set(["169.254.169.254", "metadata.google.internal"]);
const ALLOWED_HOSTS = new Set(["api.stripe.com", "hooks.slack.com"]);
const BLOCKED_RANGES = new Set([
"private", "loopback", "linkLocal", "uniqueLocal", "carrierGradeNat", "multicast",
]);
async function resolveAndValidate(hostname: string): Promise<void> {
if (METADATA_HOSTS.has(hostname)) throw new SsrfError("Blocked host");
const records = await lookup(hostname, { all: true });
for (const { address } of records) {
const range = ipaddr.parse(address).range();
if (BLOCKED_RANGES.has(range)) throw new SsrfError(`Blocked IP range: ${range}`);
}
}
class SsrfError extends Error {
constructor(message: string) {
super(message);
this.name = "SsrfError";
}
}
async function safeFetch(raw: string, init?: RequestInit): Promise<Response> {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol)) throw new SsrfError("Invalid protocol");
if (process.env.SSRF_ALLOWLIST === "true" && !ALLOWED_HOSTS.has(url.hostname)) {
throw new SsrfError("Host not allowlisted");
}
await resolveAndValidate(url.hostname);
return fetch(url, {
...init,
redirect: "manual",
signal: AbortSignal.timeout(init?.signal ? undefined : 5_000),
});
}
// Express route
import express from "express";
import { z } from "zod";
const app = express();
app.use(express.json());
app.post("/import", async (req, res) => {
const { sourceUrl } = z.object({ sourceUrl: z.string().url() }).parse(req.body);
try {
const response = await safeFetch(sourceUrl);
if (response.status >= 300 && response.status < 400) {
return res.status(400).json({ error: "Redirects not allowed" });
}
res.json({ size: (await response.arrayBuffer()).byteLength });
} catch (err) {
if (err instanceof SsrfError) return res.status(400).json({ error: err.message });
throw err;
}
});What this demonstrates:
- DNS resolution before fetch catches hostnames that resolve to
10.xor127.0.0.1. redirect: "manual"prevents bypass via 302 to internal IP.- Optional hostname allowlist for known integrations.
AbortSignal.timeoutbounds hung outbound calls.
Deep Dive
How It Works
- SSRF tricks your server into requesting internal resources the attacker cannot reach directly.
- Cloud metadata at
169.254.169.254exposes IAM credentials on AWS. - DNS rebinding: first resolve is public, second fetch hits internal - re-resolve or pin IP after check.
- Redirect chains can escape validation - disable or re-validate each hop.
Defense Layers
| Layer | Control |
|---|---|
| Protocol | http: and https: only - block file:, gopher: |
| Hostname | Block metadata hosts; allowlist when possible |
| DNS | Resolve all A/AAAA records; block private ranges |
| Redirect | manual + reject 3xx or re-validate Location |
| Egress | Network policy / firewall egress allowlist (defense in depth) |
IPv6 Notes
const parsed = ipaddr.parse(address);
if (parsed.kind() === "ipv6") {
const ipv4 = parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed;
// check range on mapped form
}- Block
::1, link-localfe80::/10, and IPv4-mapped private addresses.
When Allowlist Fits
const ALLOWED = /\.stripe\.com$/;
if (!ALLOWED.test(url.hostname)) throw new SsrfError("Not allowed");- Webhook receivers with fixed partners should deny by default.
Gotchas
- Checking hostname string only -
internal.serviceresolves to 10.x. Fix: always DNS resolve. - Following redirects automatically - attacker bounces to metadata IP. Fix:
redirect: "manual". - Allowing user-supplied IP literals -
http://127.0.0.1. Fix: block or disallow IP hosts entirely. - XML external entities (XXE) - separate SSRF vector in XML parsers. Fix: disable external entities.
- PDF/image libraries fetching URLs - same guards on any HTTP client path. Fix: central
safeFetch. - Only guarding one route - grep all
fetch(andaxios.getin CI.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| DNS resolve + IP block | General URL import features | Fixed partner set (use allowlist) |
| Domain allowlist | Known webhook providers | Arbitrary user URLs |
| Egress proxy | Enterprise zero-trust network | Simple SaaS MVP |
| No server-side fetch | Can client download directly | Need server processing |
FAQs
Is validating URL format enough?
No. new URL() accepts http://10.0.0.1 and http://169.254.169.254. Resolve and block ranges.
What about axios?
Same rules: custom httpAgent, maxRedirects: 0, validate URL before request.
Does fetch in Node 24 follow redirects?
Yes by default. Pass redirect: "manual" when user supplies the URL.
Can I use a SSRF library?
Packages like ssrf-req-filter help. Still add allowlist and timeouts for defense in depth.
Internal service-to-service calls?
Not SSRF if the URL is config-driven, not user-supplied. Use mTLS and private DNS.
Webhooks incoming vs outgoing?
Incoming: verify signatures. Outgoing SSRF is when YOU fetch user URLs - different threat.
Kubernetes metadata?
Block 169.254.169.254 and restrict pod egress with NetworkPolicy.
How to test?
Unit tests with 127.0.0.1, 10.0.0.1, and metadata IP - expect rejection before fetch mock fires.
Related
- Security Basics - SSRF intro example
- OWASP Top 10 for APIs - API7
- Security Rules - outbound fetch rules
- Timeouts Everywhere - outbound timeout
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.