Building an SSRF-Safe URL Fetcher: DNS Pinning, Redirect Re-Validation, and Cloud Metadata Defense
How the Security Header Analyzer tool validates, resolves, and pins every outbound request to defeat Server-Side Request Forgery โ including DNS rebinding and cloud metadata endpoint exploitation.
Describes the exact SSRF-guard implementation shipped in this site's own Security Header Analyzer โ not a theoretical writeup.
SSRF (Server-Side Request Forgery) lets an attacker trick a server into making requests on their behalf โ often to internal services or cloud metadata endpoints unreachable from outside. This post walks through a real, production SSRF-safe URL fetcher: structural validation, resolved-IP checking, DNS-rebinding defense via connection pinning, and per-redirect-hop re-validation.
1. What SSRF Actually Is, and Why "Check the Hostname" Isn't Enough
Server-Side Request Forgery (SSRF) is a vulnerability class (tracked as CWE-918) where an attacker supplies a URL to a server-side feature โ a webhook validator, an image-proxy, a "check this site" tool โ and gets the server to make an HTTP request on the attacker's behalf, often to a destination the attacker couldn't reach directly. The server ends up acting as a confused deputy.
The Security Header Analyzer tool on this site does exactly this by design: the user gives it a URL, and the server fetches it to read the response headers. That makes it a textbook SSRF target if built naively โ any URL-fetching feature is.
The Naive Defense That Doesn't Work
The instinctive fix is a hostname blocklist: reject localhost, reject anything starting with 192.168. or 10.. This fails for two reasons:
- A public-looking hostname can resolve to a private IP.
attacker-controlled-domain.comcan point its DNSArecord at169.254.169.254โ the cloud metadata endpoint on AWS, GCP, and Azure โ and a hostname-string check never sees that. This exact pattern was central to the 2019 Capital One breach, where an SSRF-exploitable web application was used to reach AWS's instance metadata service and retrieve credentials. - DNS is not stable between check and use. Even if you resolve the hostname once to confirm it's public, nothing stops the DNS record from changing by the time the actual HTTP connection opens โ a technique called DNS rebinding (covered in section 4).
The only defense that actually holds is validating the resolved IP address, not the hostname string, and then guaranteeing the connection you open uses that exact validated address.
2. Layer 1: Structural URL Validation
Before any network activity happens at all, reject anything structurally wrong โ this is cheap and closes off a class of trivial bypasses before DNS is even involved:
const ALLOWED_PROTOCOLS = new Set(["http:", "https:"]);
const ALLOWED_PORTS = new Set(["", "80", "443"]);
export function validateRequestUrl(input: string): UrlValidationResult {
let url: URL;
try {
url = new URL(input);
} catch {
return { ok: false, reason: "That's not a valid URL." };
}
if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
return { ok: false, reason: "Only http:// and https:// URLs are allowed." };
}
if (!ALLOWED_PORTS.has(url.port)) {
return { ok: false, reason: "Only default ports (80/443) are allowed." };
}
if (url.hostname === "localhost" || url.hostname === "0.0.0.0" || url.hostname.endsWith(".localhost")) {
return { ok: false, reason: "Requests to localhost are not allowed." };
}
if (url.username || url.password) {
return { ok: false, reason: "URLs with embedded credentials are not allowed." };
}
return { ok: true, url };
}Four checks, each closing a specific bypass: blocking non-HTTP(S) schemes stops file:// and gopher:// tricks; restricting to default ports stops probing internal services running on non-standard ports; the localhost string check is a cheap first pass (not a substitute for IP validation โ see below); and rejecting embedded credentials (https://user:pass@host) closes a URL-parsing inconsistency some HTTP clients have historically mishandled.
This layer alone stops nothing that matters. It's a fast rejection of obviously-wrong input before spending a DNS lookup on it. The real defense is next.
3. Layer 2: Validate the Resolved IP, Not the Hostname
Once a URL passes structural checks, resolve its hostname and check the actual IP address against every private, loopback, link-local, and reserved range โ including the cloud metadata range that hostname-blocklists always miss:
| CIDR Range | What It Covers |
|---|---|
0.0.0.0/8 | "This network" โ unroutable |
10.0.0.0/8 | RFC 1918 private range |
100.64.0.0/10 | Carrier-grade NAT (RFC 6598) |
127.0.0.0/8 | Loopback |
169.254.0.0/16 | Link-local โ includes the 169.254.169.254 cloud metadata endpoint on AWS/GCP/Azure |
172.16.0.0/12 | RFC 1918 private range |
192.0.0.0/24 | IETF protocol assignments |
192.0.2.0/24 | TEST-NET-1 (documentation) |
192.168.0.0/16 | RFC 1918 private range |
198.18.0.0/15 | Benchmark testing |
198.51.100.0/24 | TEST-NET-2 (documentation) |
203.0.113.0/24 | TEST-NET-3 (documentation) |
224.0.0.0/4 | Multicast |
240.0.0.0/4 | Reserved |
The check does the range comparison at the integer level, and โ critically โ fails closed on anything it can't parse:
export function isPrivateIPv4(ip: string): boolean {
const ipInt = ipv4ToInt(ip);
if (ipInt === null) return true; // unparseable โ fail closed
for (const [base, prefix] of IPV4_BLOCKED_RANGES) {
const baseInt = ipv4ToInt(base);
if (baseInt === null) continue;
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
if ((ipInt & mask) === (baseInt & mask)) return true;
}
return false;
}return true (blocked) on a parse failure, not false (allowed), is the detail that matters โ a validator that fails *open* on malformed input is a bypass waiting to be found. IPv6 gets the equivalent treatment: loopback (::1), link-local (fe80::/10), unique-local (fc00::/7), multicast (ff00::/8), the documentation range (2001:db8::/32), and IPv4-mapped addresses (::ffff:a.b.c.d) unwrapped and checked against the same IPv4 table.
4. Layer 3: DNS Rebinding โ Why Validating Isn't Enough Either
Here's the gap the first two layers leave open: DNS is not stable. You resolve attacker.com to 93.184.216.34 (public, passes validation) โ but by the time your HTTP client independently re-resolves that hostname a few milliseconds later to actually open the connection, the attacker's authoritative DNS server has changed the answer to 169.254.169.254. This is DNS rebinding, and it defeats "validate then fetch" if the fetch step performs its own DNS lookup.
The Fix: Pin the Connection to the Address You Already Validated
The fetcher resolves the hostname exactly once, validates that address, and then forces the actual TCP connection to use that specific IP โ never re-resolving โ via Node's custom lookup option on http.request/https.request:
function requestOnce(url: URL, pinnedAddress: string): Promise<...> {
return new Promise((resolve, reject) => {
const lookup: https.RequestOptions["lookup"] = (_hostname, options, callback) => {
const family = pinnedAddress.includes(":") ? 6 : 4;
// Node's Happy Eyeballs (autoSelectFamily) requests the array form;
// disabled below via autoSelectFamily: false, but handle both shapes
// defensively since that default has changed across versions.
if (typeof options === "object" && options.all) {
callback(null, [{ address: pinnedAddress, family }]);
} else {
callback(null, pinnedAddress, family);
}
};
const options: https.RequestOptions & { autoSelectFamily?: boolean } = {
hostname: url.hostname,
lookup, // connect to the already-validated address, never re-resolve
autoSelectFamily: false,
port: Number(url.port) || (url.protocol === "https:" ? 443 : 80),
path: `${url.pathname}${url.search}`,
method: "GET",
headers: { "User-Agent": "moayyadfaris.com-security-header-analyzer/1.0", Accept: "*/*" },
timeout: 5000,
};
const req = url.protocol === "https:" ? https.request(options, onResponse) : http.request(options, onResponse);
req.on("timeout", () => req.destroy(new Error("timeout")));
req.on("error", reject);
req.end();
});
}Two details worth calling out: hostname: url.hostname is still passed alongside the custom lookup so TLS SNI and the Host header โ and therefore certificate validation โ still target the real hostname; only the *connection's* destination IP is pinned. And autoSelectFamily: false disables Node's Happy Eyeballs dual-stack racing, because that feature exists to race multiple resolved addresses against each other โ there's nothing to race once you've already pinned to one specific address.
5. Layer 4: Redirects Are a Second Attack Surface
A URL that passes every check above can still redirect to http://169.254.169.254/. If the HTTP client follows redirects automatically without re-running the full validation pipeline on each hop, every defense built so far is bypassed by a single 302 response.
The fix is structural: redirects are handled in a loop, and every single hop re-runs structural validation, DNS resolution, and IP-range checking from scratch โ a redirect target gets exactly the same scrutiny as the original URL, with a hard cap on hop count:
const MAX_REDIRECTS = 3;
export async function fetchHeadersSafely(inputUrl: string) {
let currentUrl = inputUrl;
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
const validation = validateRequestUrl(currentUrl);
if (!validation.ok || !validation.url) return { error: validation.reason ?? "Invalid URL." };
const resolution = await resolveSafely(validation.url.hostname);
if ("error" in resolution) return resolution;
const response = await requestOnce(validation.url, resolution.address);
const location = response.headers.location;
const isRedirect = response.statusCode >= 300 && response.statusCode < 400 && location;
if (isRedirect) {
if (redirectCount === MAX_REDIRECTS) return { error: "Too many redirects." };
currentUrl = new URL(location, validation.url).toString();
continue;
}
return { finalUrl: currentUrl, statusCode: response.statusCode, headers: flattenHeaders(response.headers) };
}
return { error: "Too many redirects." };
}One more detail that matters for a tool that only needs response *headers*: the response handler calls res.destroy() immediately after reading the status and headers, before ever reading the body. This isn't an SSRF control specifically, but it closes an adjacent risk โ an attacker-controlled endpoint returning an effectively unbounded response body can't be used to exhaust memory or bandwidth on a request the tool never needed to fully read.
6. Defense in Depth: Rate Limiting, Timeouts, and Honest Limitations
SSRF prevention is the core of this tool's threat model, but a public URL-fetching endpoint needs a couple of supporting controls to be safe to expose at all.
Sliding-Window Rate Limiting
const buckets = new Map<string, number[]>();
export function checkRateLimit(key: string, limit: number, windowMs: number): RateLimitResult {
const now = Date.now();
const timestamps = (buckets.get(key) ?? []).filter((t) => now - t < windowMs);
if (timestamps.length >= limit) {
buckets.set(key, timestamps);
return { allowed: false, remaining: 0 };
}
timestamps.push(now);
buckets.set(key, timestamps);
return { allowed: true, remaining: limit - timestamps.length };
}The API route calls this with a 20-requests-per-60-seconds budget keyed on the client's forwarded IP, before any URL parsing happens at all โ cheap enough that it isn't worth spending validation or DNS-lookup cycles on a client that's already over budget.
A Hard Request Timeout
Every outbound request carries a 5-second timeout, destroying the connection if the target never responds โ closing off a slow-loris-style resource-exhaustion angle where an attacker-controlled endpoint just never sends headers.
What This Doesn't Cover โ Stated Plainly
- The rate limiter is in-memory, per-instance. It resets on redeploy and isn't shared across serverless instances โ a reasonable baseline for a small site's traffic volume, not a defense against a distributed abuse campaign. A shared store (Redis) is the documented upgrade path if abuse ever becomes real.
- This is IP-range filtering, not a full egress firewall. It stops SSRF to well-known private/reserved ranges; it does not replace network-level egress controls in an environment where that's available (e.g. routing all outbound traffic through a controlled NAT gateway with its own allowlist).
- DNS-over-HTTPS resolvers and split-horizon DNS are out of scope. The guard assumes Node's standard resolver behavior; an environment with unusual DNS infrastructure should validate this pipeline against its own resolver setup rather than assuming it transfers unchanged.
Try the live implementation with the [Security Header Analyzer](/tools/security/security-header-analyzer) โ every fetch it performs runs through this exact pipeline.
Frequently Asked Questions
What is SSRF (Server-Side Request Forgery)?
SSRF (CWE-918) is a vulnerability where an attacker supplies a URL to a server-side feature and tricks the server into making an HTTP request on the attacker's behalf โ often reaching internal services, cloud metadata endpoints, or other destinations the attacker couldn't reach directly themselves.
Why isn't checking the hostname string enough to prevent SSRF?
A public-looking hostname can have its DNS record point at a private or reserved IP address โ including cloud metadata endpoints like 169.254.169.254 on AWS/GCP/Azure. A hostname-string blocklist never sees the resolved IP, so it misses this entirely. The only reliable check is validating the actual resolved address.
What is DNS rebinding and how does connection pinning prevent it?
DNS rebinding is when a hostname resolves to a safe IP during validation, but resolves to a different (malicious) IP by the time the actual HTTP connection opens โ because DNS records can change between the two lookups. Connection pinning closes this gap by resolving the hostname exactly once, validating that address, and forcing the TCP connection to use that specific pinned address via a custom lookup function, rather than letting the HTTP client re-resolve independently.
Why do HTTP redirects need separate SSRF validation?
A URL can pass every validation check and then respond with a redirect to an internal or metadata address. If an HTTP client follows redirects automatically without re-validating each new location, the entire SSRF defense is bypassed by a single 3xx response โ so every redirect hop needs to go through the same structural validation, DNS resolution, and IP-range checking as the original URL, with a hard cap on hop count.