I Tested My Own SSRF Guard and [::1] Walked Straight Through
Two of my products fetch URLs that users give them. While writing about server-side request forgery, I threw a list of tricky addresses at my own guard. The URL parser was hiding the bug.
contents (8)
I set out to write a guide to server-side request forgery. Before writing it, I wanted to check the guard in my own code, so I put together a list of tricky addresses and ran them through it.
The first IPv6 address on the list got through. So did every other one.
Two products I built fetch URLs that users give them: a security scanner and a screenshot API. Both had a guard meant to stop exactly this. Both had a hole in it. I fixed and redeployed both the same night. This post is what I found, why it was easy to miss, and the test list that caught it.
What SSRF is
If your server fetches a URL that a user controls, the user can point your server at places they cannot reach themselves:
http://localhost:8080/admin, an internal service that trusts anything on the same machine.http://10.0.0.5/, another machine on your private network.http://169.254.169.254/, the cloud metadata address, which on many platforms hands out credentials to anything that asks.
The request comes from your server, inside your network, so it gets your server’s level of trust. That is server-side request forgery. Any feature that takes a URL as input can be vulnerable: link previews, webhooks, image imports, website scanners and screenshot tools.
The standard defence is a guard: before fetching, parse the URL, look at the host, and refuse anything private.
The guard looked right
Simplified, the IPv6 part of mine looked like this:
function isPrivateIPv6(hostname: string): boolean { const h = hostname.toLowerCase(); return h === '::1' || h === '::' || h.startsWith('fc') || h.startsWith('fd') || h.startsWith('fe8');}
const url = new URL(input);if (isPrivateIPv6(url.hostname)) throw new Error('not allowed');It checks loopback, unique-local and link-local addresses. It reads like it works. Here is what new URL() actually gives it:
new URL('http://[::1]/').hostname // '[::1]'new URL('http://[fd00::1]/').hostname // '[fd00::1]'new URL('http://[::ffff:127.0.0.1]/').hostname // '[::ffff:7f00:1]'new URL('http://[::ffff:169.254.169.254]/').hostname // '[::ffff:a9fe:a9fe]'The hostname keeps its square brackets. '[::1]' === '::1' is false. '[fd00::1]'.startsWith('fd') is false. Every IPv6 check failed, so every IPv6 literal was allowed, including loopback and the metadata address written as an IPv4-mapped IPv6 address.
The second product had already handled the brackets. It failed one step later: it checked IPv4-mapped addresses by their dotted prefix, ::ffff:127., but the parser rewrites them in hex, as ::ffff:7f00:1. That prefix never matches.
The parser helps with IPv4 and hurts with IPv6
The irony is that the same parser quietly protects you from the classic IPv4 tricks:
| Input | url.hostname |
|---|---|
http://2130706433/ |
127.0.0.1 |
http://0x7f.1/ |
127.0.0.1 |
http://127.1/ |
127.0.0.1 |
http://[::ffff:127.0.0.1]/ |
[::ffff:7f00:1] |
http://localhost./ |
localhost. |
Decimal, hex and shortened IPv4 all come out as plain dotted IPv4, so a check on the parsed hostname catches them. IPv6 comes out in brackets and in compressed hex. And a trailing dot, which DNS treats as the same name, survives untouched, so localhost. slipped past a check for localhost.
So the rule is not “parse the URL and check the hostname”. It is parse the URL, normalise the hostname, then parse the address itself.
The fix: normalise, then parse the address
First, strip what the URL parser leaves on:
function bareHost(hostname: string): string { return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');}Then stop comparing strings. Expand the IPv6 address into its eight 16-bit groups, so every way of writing the same address looks the same:
function parseIPv6(address: string): number[] | null { let addr = address.split('%')[0]; // A dotted IPv4 tail ("::ffff:1.2.3.4") becomes two groups. const v4 = addr.match(/^(.*:)(\d+\.\d+\.\d+\.\d+)$/); if (v4) { const [a, b, c, d] = v4[2].split('.').map(Number); addr = `${v4[1]}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`; } const halves = addr.split('::'); if (halves.length > 2) return null; const head = halves[0] ? halves[0].split(':') : []; const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; const missing = 8 - head.length - tail.length; if (halves.length === 1 ? head.length !== 8 : missing < 1) return null; const parts = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail]; if (!parts.every((p) => /^[0-9a-f]{1,4}$/.test(p))) return null; return parts.map((p) => parseInt(p, 16));}Now the checks work on numbers. And the most important one: an IPv6 address can carry an IPv4 address inside it, so pull that out and run it through the IPv4 rules you already have:
function isPrivateIPv6(h: number[]): boolean { const zeros = (from: number, to: number) => h.slice(from, to).every((x) => x === 0); const embeddedIPv4 = [h[6] >> 8, h[6] & 0xff, h[7] >> 8, h[7] & 0xff].join('.');
if (zeros(0, 8)) return true; // :: if (zeros(0, 7) && h[7] === 1) return true; // ::1 // ::ffff:a.b.c.d (mapped), ::a.b.c.d (compatible), 64:ff9b::a.b.c.d (NAT64) if (zeros(0, 5) && (h[5] === 0xffff || h[5] === 0)) return isPrivateIPv4(embeddedIPv4); if (h[0] === 0x64 && h[1] === 0xff9b && zeros(2, 6)) return isPrivateIPv4(embeddedIPv4); if ((h[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local if ((h[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local if ((h[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast return false;}One more rule: if something looks like an IPv6 address but does not parse, refuse it. A guard should fail closed.
A public name can point somewhere private
Checking the hostname only covers addresses typed directly into the URL. A perfectly ordinary-looking domain can resolve to a private address. localtest.me, for example, resolves to 127.0.0.1.
So for hostnames, resolve them and check every answer:
const [a, aaaa] = await Promise.all([resolve(host, 'A'), resolve(host, 'AAAA')]);if ([...a, ...aaaa].some(isPrivateAddress)) throw new Error('not allowed');The scanner already did this. The screenshot API did not, so localtest.me went straight through until the fix.
This has a known limit. The guard resolves the name, then the fetch resolves it again, and a malicious DNS server can give a public answer the first time and a private one the second. This is called DNS rebinding. The stronger defences are connecting to the exact IP you checked, or running the fetch where private networks are unreachable. My services run on Cloudflare Workers, whose network limits what an outbound request can reach, which likely blunted all of this. But the platform is not a reason to leave the guard wrong. It is the part I control.
Check every redirect, not just the first URL
A guard on the first URL is not enough if your HTTP client follows redirects. The attacker gives you https://innocent.example, which answers with a redirect to http://169.254.169.254/.
So turn off automatic redirects and run the guard on every hop:
async function safeFetch(input: string, maxRedirects = 5): Promise<Response> { let current = input; for (let hop = 0; hop <= maxRedirects; hop++) { const url = await validatePublicTarget(current); // throws on private targets const res = await fetch(url, { redirect: 'manual' }); const location = res.headers.get('location'); if (res.status < 300 || res.status >= 400 || !location) return res; current = new URL(location, url).toString(); } throw new Error('too many redirects');}A headless browser makes its own requests
The screenshot API is a harder case. It loads the page in a headless browser, and the page then fetches its own images, scripts and frames. Any of those can point at an internal address, and none of them go through your safeFetch.
The answer is request interception: every request the browser makes passes through your check first.
await page.setRequestInterception(true);page.on('request', (req) => { if (isBlockedRequestUrl(req.url())) req.abort(); else req.continue();});There is a trade-off here. A page can make hundreds of requests, so resolving DNS for each one is too slow. Subrequests get the fast check on the literal host, and the full DNS check runs once, on the target URL, before the browser launches.
The test list that found it
The guard had been in production for months. It looked right to me every time I read it. What found the bug in minutes was a table of inputs and a test that runs each one:
const blocked = [ 'http://[::1]/', 'http://[::]/', 'http://[fd00::1]/', 'http://[fe80::1]/', 'http://[ff02::1]/', 'http://[::ffff:127.0.0.1]/', 'http://[::ffff:169.254.169.254]/', 'http://[::ffff:10.0.0.1]/', 'http://[64:ff9b::a9fe:a9fe]/', 'http://127.0.0.1/', 'http://2130706433/', 'http://0x7f.1/', 'http://127.1/', 'http://169.254.169.254/', 'http://localhost./', 'http://127.0.0.1./', 'http://metadata.google.internal/',];
const allowed = [ 'https://example.com/', 'http://8.8.8.8/', 'http://[2606:4700:4700::1111]/', 'http://[::ffff:8.8.8.8]/',];
it.each(blocked)('refuses %s', async (url) => { await expect(validatePublicTarget(url)).rejects.toThrow();});
it.each(allowed)('allows %s', async (url) => { await expect(validatePublicTarget(url)).resolves.toBeInstanceOf(URL);});The allowed list matters as much as the blocked one. A guard that refuses everything is secure and useless, and a fix that blocks [::ffff:8.8.8.8] has gone too far.
If you have a feature that fetches user-supplied URLs, copy that list into your tests and run it. If every line passes, good. If [::1] gets through, you are in the same place I was, and the fix is an evening’s work.