User-supplied URLs (avatar, webhook, import from URL) turn your Node service into an HTTP client inside the VPC. Attackers aim at 169.254.169.254, link-local, and DNS rebinding. The unfair advantage is pinning the resolved address at connect time—deny private ranges after DNS, not just after parsing the hostname string.
⚡ TL;DR: Parse URL; resolve DNS yourself; reject private/link-local/metadata IPs; connect to the pinned address with
Hostheader; block redirects to private targets; prefer egress proxy allowlists. Pair with Lambda Outbound Proxies and Node Permission Model.
Why hostname denylists fail
1) Attacker URL: http://evil.example/img → resolves to 1.2.3.4 (public) ✅ passes check
2) TTL expires / rebind → 169.254.169.254 at connect time ❌ SSRF
// ✅ Pin address after resolution (sketch)
import dns from "node:dns/promises";
import net from "node:net";
import { Agent, fetch } from "undici";
const BLOCK = [
/^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[0-1])\./, /^192\.168\./,
/^169\.254\./, /^0\./, /^100\.64\./, /^::1$/, /^fc00:/i, /^fe80:/i,
];
function isBlockedIp(ip: string): boolean {
return BLOCK.some((re) => re.test(ip));
}
export async function safeFetch(userUrl: string): Promise<Response> {
const u = new URL(userUrl);
if (!["http:", "https:"].includes(u.protocol)) throw new Error("scheme");
const { address, family } = await dns.lookup(u.hostname, { verbatim: true });
if (isBlockedIp(address)) throw new Error("ssrf_blocked");
// Connect to pinned IP; send original Host
const pinned = family === 6 ? `http://[${address}]${u.pathname}${u.search}`
: `http://${address}${u.pathname}${u.search}`;
const httpsUrl = u.protocol === "https:"
? pinned.replace("http://", "https://")
: pinned;
return fetch(httpsUrl, {
method: "GET",
redirect: "manual", // ✅ inspect Location yourself
headers: { Host: u.host },
dispatcher: new Agent({ connect: { timeout: 2000 } }),
});
}
// ❌ Trusting only URL.hostname string checks
if (userUrl.includes("169.254.169.254")) throw new Error("no");
await fetch(userUrl); // rebinding / DNS / redirects still bite
Redirects and IPv6 tricks
Follow redirects manually: re-run the IP check on every Location. Deny decimal/octal IP forms, DNS to @, and credentialed URLs. Prefer an egress proxy (Lambda Outbound Proxies) so the task role never reaches IMDS.
| Control | Stops |
|---|---|
| Pin + private IP deny | Direct metadata / RFC1918 |
| Manual redirects | Open redirect to IMDS |
| IMDS hop limit / IMDSv2 | Host metadata from task |
| Egress allowlist proxy | Defense in depth |
Closing checklist
✅ Dos
– ✅ Resolve then deny private/link-local/metadata ranges
– ✅ Pin IP for the TCP connect; set Host explicitly
– ✅ Re-validate on every redirect hop
– ✅ Force IMDSv2 + hop limit on EC2/ECS hosts
– ✅ Default-deny egress except proxy / known peers
❌ Don’ts
– ❌ Don’t fetch(userUrl) with auto-redirect
– ❌ Don’t rely on string denylists alone
– ❌ Don’t allow file:, gopher:, or weird schemes
– ❌ Don’t give the app role broad ec2:Describe* just because SSRF “might need it”
Related reading
- Lambda Outbound Proxies: Egress Allowlists That Block SSRF Paths
- Node Permission Model: Lock Down Production Processes Running Plugins
- Secret-Aware Context Filters
- Agent Tool Allowlists
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
