Three weeks ago, in the comments under a post about a different SSRF bug (CVE-2026-19304, a parser-confusion issue), someone asked a sharp question about our own URL-fetching endpoints. I answered honestly that we'd only tested that our hostname-validation and the actual fetch agreed on parsing the same string — and that whether a validated hostname could resolve to something different by the time we actually connected was a real gap we hadn't checked. I said I'd go find out whether Cloudflare Workers even supports pinning a connection to a specific IP.
Then I didn't follow up. The comment sat there for weeks.
Going back to it tonight, the gap was worse than I'd worried.
What isBlockedHostname() actually checked
Five of our endpoints (redirect-trace, security-scan, security-headers, favicon, scrape) fetch a user-supplied URL. All five guarded against SSRF the same way: a regex blocklist checked against the hostname string before fetching.
\js
function isBlockedHostname(hostname) {
return BLOCKED_HOSTNAME_PATTERNS.some((re) => re.test(hostname));
}
\\
This blocks "127.0.0.1" and "localhost" directly. It does nothing for a hostname that resolves to 127.0.0.1. The check and the actual fetch() are two separate, uncontrolled DNS lookups, with a window between them where nothing stops the second lookup from answering differently than the first — classic DNS rebinding.
I tested this against a live rebinding domain (rbndr.us, which alternates its DNS answer between 127.0.0.1 and a public IP depending on which query hits it) against our own production endpoint. It went straight through.
The fix: don't trust a second DNS lookup you don't control
Cloudflare Workers exposes exactly what's needed for this: cf.resolveOverride on the fetch() request options. You resolve the hostname yourself, validate the IP you actually got back, then pin the real connection to that exact address — the Host header still matches the original URL, but no second, attacker-influenced DNS lookup happens between validation and connection.
\jsBlocked hostname: \${hostname}
async function validateAndResolve(hostname) {
if (isBlocked(hostname)) throw new Error(\);Resolves to a blocked address: \${blockedIp}`);
const ips = await resolveViaDoH(hostname); // Cloudflare's own DoH resolver
const blockedIp = ips.find(isBlocked);
if (blockedIp) throw new Error(\
return ips[0];
}
async function safeFetch(url, options = {}) {
const { hostname } = new URL(url);
const ip = await validateAndResolve(hostname);
return fetch(url, { ...options, cf: { resolveOverride: ip } });
}
`\
Ran this against the same rebinding domain, five times in a row: rejected every time, with the specific blocked IP named in the error. A normal external host still worked exactly as before.
The second bug, found by the fix looking suspicious
Wiring this into the endpoints that follow redirects (not just the initial URL — a redirect chain that starts safe and pivots partway through is the same bug one hop later), one of them started returning results that looked wrong: a real HTTP grade and score, for a URL that should have been blocked.
Before assuming the fix was broken, I checked what it was actually connecting to. The rebinding domain's "safe" branch resolves to 8.8.8.8 — and it turns out Google's public DNS server responds to a plain HTTP GET with real headers (X-Frame-Options, Referrer-Policy, a few others). I confirmed this by hitting 8.8.8.8 directly, outside any rebinding context, and got the identical response. The "suspicious" result was the fix working correctly against a resolution that was, in fact, a genuinely public IP — not a bypass.
Worth saying plainly: I almost mis-read a correct result as a failure, because I'd assumed which branch of the test domain I was hitting instead of checking.
One more bug, unrelated, found along the way
One of the five endpoints (the one that scrapes and parses page HTML) started failing with Cloudflare's own resource-limit error on a large real page, but not on a small one. The redirect-safe fetch itself wasn't the cause — an existing streaming-reader loop was rebuilding the entire accumulated byte array on every chunk received (chunks.reduce((acc, c) => new Uint8Array([...acc, ...c]))), which is O(n²) and was apparently already close to the CPU-time ceiling before this session added a DNS lookup on top of it. Switched to allocating the final buffer once and copying each chunk in at its offset — the standard pattern, just not the one that was there.
Where it landed
All five endpoints now resolve, validate, and pin every hop of a fetch, not just the entry URL. Verified against a live rebinding domain and a full endpoint-by-endpoint pass afterward, not just a read of the diff.
If you're doing SSRF protection in a Workers/edge-function context specifically (no raw sockets, no custom DNS resolver at the runtime level) — curious whether resolveOverride is the tool most people reach for, or if there's a cleaner pattern I'm missing. And a genuine thanks to whoever asks the question that makes you actually go check, instead of just saying you will.
Top comments (0)