SSRF prevention in Node.js — validate the resolved IP, not the URL string
SSRF (Server-Side Request Forgery) is how attackers reach your cloud metadata service and internal APIs through your public endpoints. Here is the correct defense.
SSRF defenses that check the URL string for `localhost` or `127.0.0.1` are all defeated by clever encodings. The only reliable defense is to resolve the hostname to an IP, reject private IP ranges, then issue the request.
What it is
SSRF happens when your server accepts a user-supplied URL and makes an outbound request to it. If the URL resolves to a private IP (AWS metadata, internal DB, loopback), the attacker has extracted value from your infrastructure's trust boundary.
Vulnerable example
// Vulnerable: string-based allowlist is trivially bypassable
import { URL } from "url";
function isPrivate(url: string) {
const u = new URL(url);
const blocked = ["localhost", "127.0.0.1", "169.254.169.254"];
return blocked.includes(u.hostname);
}
export async function GET(req: Request) {
const u = new URL(req.url).searchParams.get("url")!;
if (isPrivate(u)) return new Response("nope", { status: 400 });
// Attacker URL: http://[::]:80/ or http://0x7f.0x0.0x0.0x1/
// passes the check, resolves to loopback.
const resp = await fetch(u);
return new Response(await resp.text());
}Fixed example
// Fixed: resolve, validate IP, then fetch.
import dns from "dns/promises";
import net from "net";
const PRIVATE_RANGES = [
"10.", "172.16.", "172.17.", "172.31.", "192.168.",
"127.", "169.254.", "::1", "fc00:", "fe80:",
];
async function isPublicHttp(u: URL) {
if (!/^https?:$/.test(u.protocol)) return false;
const ips = await dns.resolve(u.hostname);
return ips.every((ip) => {
if (!net.isIP(ip)) return false;
if (PRIVATE_RANGES.some((p) => ip.startsWith(p))) return false;
return true;
});
}
export async function GET(req: Request) {
const u = new URL(new URL(req.url).searchParams.get("url")!);
if (!(await isPublicHttp(u))) return new Response("nope", { status: 400 });
const resp = await fetch(u, { redirect: "error", signal: AbortSignal.timeout(3000) });
return new Response(await resp.text());
}How Securie catches it
apps/web/app/api/fetch/route.ts:19SSRF prevention in Node.js
Securie's SSRF specialist traces every outbound fetch with a user-controlled URL and checks for hostname-string allowlists (which it flags) versus resolved-IP allowlists (which it approves).
// Fixed: resolve, validate IP, then fetch.
import dns from "dns/promises";
import net from "net";
const PRIVATE_RANGES = [
"10.", "172.16.", "172.17.", "172.31.", "192.168.",
"127.", "169.254.", "::1", "fc00:", "fe80:",
];
async function isPublicHttp(u: URL) {
if (!/^https?:$/.test(u.protocol)) return false;
const ips = await dns.resolve(u.hostname);
return ips.every((ip) => {
if (!net.isIP(ip)) return false;
if (PRIVATE_RANGES.some((p) => ip.startsWith(p))) return false;
return true;
});
}
export async function GET(req: Request) {
const u = new URL(new URL(req.url).searchParams.get("url")!);
if (!(await isPublicHttp(u))) return new Response("nope", { status: 400 });
const resp = await fetch(u, { redirect: "error", signal: AbortSignal.timeout(3000) });
return new Response(await resp.text());
}Checklist
- Resolve hostname to IP before issuing the request
- Reject all private / loopback / link-local / metadata-service IPs
- Disable redirects (redirect: 'error') or re-validate the target after every redirect
- Enforce a short timeout (AbortSignal.timeout(3000))
- Use a dedicated egress proxy with an allowlist for high-security contexts
- Log every fetch destination for audit
FAQ
What about the AWS metadata IMDSv2?
IMDSv2 requires a token header. Your SSRF attacker cannot set headers; IMDSv2 defeats the classic metadata attack. Ensure IMDSv1 is disabled on every EC2 instance.
Does the `ip` package protect me?
Only if you update it past CVE-2024-29415. Previous versions allowed octal/hex bypass.
Related guides
Row-Level-Security bypass is the most common data leak in vibe-coded apps. Here is exactly how it happens, how attackers find it, and how to fix it in Next.js + Supabase with one policy update.
BOLA is the top item on the OWASP API Security Top 10 for a reason — every AI coding assistant introduces it by default. Learn what it looks like in Next.js, how to exploit it, and how to fix it.
IDOR is the classic name for an authorization bug where a user can change an ID in a URL and access data they should not see. It is BOLA's older cousin and still ships in half of all new apps.
Every week founders tweet about their OpenAI bill going from $10 to $10,000 overnight. Usually the cause is an API key committed to a public repo. Here is why it happens in Next.js specifically and how to stop it in five minutes.