- Blog/
I Built an SSRF Guard for Cloudflare Workers. It Was Bypassable Three Ways.
I’m building a small uptime monitor on Cloudflare Workers. Part of the job is fetching URLs a user gives it — which means the app is, by design, an SSRF oracle unless you stop it from being one.
My first-cut guard looked reasonable: parse the URL, reject anything that’s obviously a private IP literal, fetch it. It took about ten minutes of adversarial review to find three ways through it. None of them are exotic — they’re the same three mistakes most homegrown SSRF guards make. Here they are, in the order I’d have found them if I’d attacked my own code first.
1. Checking A records and forgetting AAAA #
The guard resolved the hostname, checked the IPv4 result against a private-range
list, and moved on. That’s fine right up until the target has an AAAA-only
answer — ::1, or anything in fc00::/7 (unique local addresses, IPv6’s
answer to RFC1918). No A record exists, so an A-only check finds nothing to
object to and fetches straight into loopback or an internal IPv6 host.
Fix: resolve both record types before deciding. On Workers you don’t get a
native resolver, so DoH is the practical option —
cloudflare-dns.com/dns-query?type=1 for A, type=28 for AAAA (dns.google/resolve
works as a same-shape fallback if you want a second provider).
2. Guarding the first hop and trusting every redirect after it #
Checking the URL the user submitted is necessary but not sufficient — a
fetch() that follows redirects will happily walk from a clean public host to
an internal one on hop two. String-validating the Location header (scheme
check, reject obvious private literals) feels like coverage but isn’t: the
redirect’s hostname can be entirely public-looking and still resolve to a
private IP.
Fix: put the resolved-IP check inside the function that actually opens the connection, and run it on every hop, not just the one the user typed.
3. Trusting new URL() to catch obfuscated IPs — for literals only
#
WHATWG’s URL parser normalizes decimal, octal, and hex IP encodings for you:
http://2130706433, http://0177.0.0.1, http://0x7f.1 all come out the
other end as 127.0.0.1. That’s genuinely useful — a post-parse
isPrivateIpv4() check catches every obfuscation trick applied to a literal,
for free.
The trap is generalizing that relief to hostnames. URL normalizes literals
it can see in the string. It cannot see what a hostname resolves to. If your
mental model is “I normalize the URL, so I’m covered,” you’ve covered exactly
the attack surface that was already easy to catch by regex, and left the one
that actually matters — DNS-based bypass — wide open.
The one thing I didn’t fully close, and why that’s the right call #
Even with all three fixed, there’s a residual gap: fetch() re-resolves DNS
at connect time, and Workers gives you no IP-pinning for arbitrary hosts
(cf.resolveOverride only helps for hosts already in a zone on your account).
A TTL-0 DNS-rebind between your resolve check and the actual fetch stays
theoretically open.
I documented this as an accepted risk instead of chasing a fix, for a
concrete reason: Cloudflare’s edge egress doesn’t route to RFC1918 space and
there’s no tenant metadata endpoint sitting at a fixed address the way AWS has
169.254.169.254. The blast radius of a successful rebind here is close to
nil. Calling a residual gap “accepted, and here’s the specific reason” is a
different — and more honest — thing than pretending it’s closed.
TL;DR if you’re building anything that fetches user-supplied URLs #
- Resolve both A and AAAA before you decide anything.
- Check the resolved IP at the point of connection, on the initial host and every redirect hop — not just once, upstream.
new URL()normalization defeats obfuscated IP literals. It does nothing for hostnames that resolve to a bad address. Don’t let one make you feel covered for the other.- If your platform can’t fully close DNS-rebind TOCTOU, say so explicitly and write down why the residual risk is acceptable — don’t leave it implicit.
I do web app pentests on the side — SSRF, auth logic, and the usual OWASP top 10 territory. If you’re shipping something that fetches user input and want a second pair of eyes on it: [email protected].