SSRF Guards
Prevent Server-Side Request Forgery - block fetches to internal IPs, cloud metadata endpoints, and private networks when users supply URLs.
Search across all documentation pages
Prevent Server-Side Request Forgery - block fetches to internal IPs, cloud metadata endpoints, and private networks when users supply URLs.
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:
fetch(userSuppliedUrl) in the codebase.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:
10.x or 127.0.0.1.redirect: "manual" prevents bypass via 302 to internal IP.AbortSignal.timeout bounds hung outbound calls.169.254.169.254 exposes IAM credentials on AWS.| 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) |
const parsed = ipaddr.parse(address);
if (parsed.kind() === "ipv6") {
const ipv4 = parsed.isIPv4MappedAddress() ? parsed.toIPv4Address() : parsed;
// check range on mapped form
}::1, link-local fe80::/10, and IPv4-mapped private addresses.const ALLOWED = /\.stripe\.com$/;
if (!ALLOWED.test(url.hostname)) throw new SsrfError("Not allowed");internal.service resolves to 10.x. Fix: always DNS resolve.redirect: "manual".http://127.0.0.1. Fix: block or disallow IP hosts entirely.safeFetch.fetch( and axios.get in CI.| 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 |
No. new URL() accepts http://10.0.0.1 and http://169.254.169.254. Resolve and block ranges.
Same rules: custom httpAgent, maxRedirects: 0, validate URL before request.
Yes by default. Pass redirect: "manual" when user supplies the URL.
Packages like ssrf-req-filter help. Still add allowlist and timeouts for defense in depth.
Not SSRF if the URL is config-driven, not user-supplied. Use mTLS and private DNS.
Incoming: verify signatures. Outgoing SSRF is when YOU fetch user URLs - different threat.
Block 169.254.169.254 and restrict pod egress with NetworkPolicy.
Unit tests with 127.0.0.1, 10.0.0.1, and metadata IP - expect rejection before fetch mock fires.
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.
Reviewed by Chris St. John·Last updated Jul 19, 2026