We slipped a path past Cloudflare's edge. The fix is one checkbox.
Here is a request that should not work, and on most Cloudflare zones it does. Ask the edge for /pub/%2e%2e/admin and Cloudflare looks at it, decides it does not climb out of your site root, and forwards it to your origin exactly as written. Your origin reads %2e%2e, recognises it as .., and serves you /admin. If you had a rule at the edge that blocks /admin, it just did nothing, because the edge never saw /admin. It saw /pub/%2e%2e/admin.
We found this the way we find most things now, by pointing Keith, our byte-exact HTTP prober, at infrastructure we own and reading the literal bytes that came out the far end. The setup is the boring, honest kind: a Cloudflare zone we control, sitting in front of an nginx origin we control on a throwaway cloud box. The origin's only job was to log two things for every request, the path Cloudflare forwarded and the path nginx resolved it to, and then we asked the same question a dozen ways.
The answer came back the same every time:
GET /admin -> 200 forwarded=/admin resolved=/admin
GET /pub/%2e%2e/admin -> 200 forwarded=/pub/%2e%2e/admin resolved=/admin
GET /pub/%2E%2E/admin -> 200 forwarded=/pub/%2E%2E/admin resolved=/admin
GET /pub/..%2fadmin -> 200 forwarded=/pub/..%2fadmin resolved=/admin
The forwarded= column is what Cloudflare sent on. The resolved= column is what nginx decided it meant. They do not match, and that gap is the whole bug. Lower-case %2e, upper-case %2E, an encoded slash in ..%2f: every variant walked through the edge untouched and got decoded at the origin.
The mechanism is almost reasonable. Cloudflare does resolve dot-segments, but only far enough to reject a path that would escape your site root. For a path that stays inside the root, it forwards the request target raw, still URL-encoded and un-collapsed. On its own that is a defensible choice. The trouble is what sits on either side of it. Everything Cloudflare decides about a request keys off the literal path it sees: your WAF path rules, your cache key, your Workers routing, your Access policies. Your origin, meanwhile, decodes and resolves that path by default, because that is what web servers do. nginx does it for location matching and for proxy_pass. So the edge polices one string and the origin acts on another.
That mismatch is request smuggling's quieter cousin. You do not need to confuse two servers about where a request ends; you only need them to disagree about what the request is for. Use it to reach a path your edge rules block. Use it to poison a cache, by getting a sensitive response stored under a key that looks harmless. Use it to slip past an Access rule that trusts the path. None of it needs a clever payload, just an encoded dot and a server on each end behaving exactly as configured.
I want to be straight about what this is and is not. It is not a Cloudflare vulnerability, and it is not new. Path-normalisation bypasses are a known class with their own advisories across nginx and Envoy, and Cloudflare ships a setting that closes it. What makes it worth a post is not novelty, it is prevalence. The mitigation is off by default, and the origin behaviour that completes the chain is on by default, so the insecure combination is the out-of-the-box state on both ends. A very large number of zones are carrying it right now without knowing.
The part I like is that Cloudflare already tells you. On a zone without the mitigation enabled, the dashboard shows a banner that reads, near enough word for word, "ACTION REQUIRED: URL Normalization has NOT been enabled on your zone." It is easy to scroll past, filed mentally with every other "you could enable this" nudge. It is not one of those. It is the one that stops the request at the top of this post.
The setting is called Normalize URLs to origin. Turn it on and Cloudflare resolves and collapses the path before it forwards, so the edge and the origin finally agree on what they are looking at. That is the one checkbox. While you are in there, the defence-in-depth version is worth saying out loud too: do not enforce path-based access control only at a normalising edge. If /admin has to be protected, the origin should enforce that as well, so a future edge quirk cannot quietly undo it. Edge rules are a filter, not a fence.
One last thing, because it would be unfair to single Cloudflare out. We ran the same probes against other edges. Google's front-end canonicalises the obvious dot-segments and hands you a visible redirect instead of silently forwarding, though it still passes the encoded-slash form through to an origin that may decode it. Envoy normalises or rejects the lot. There is a spectrum here, and Cloudflare's default sits at the permissive end with a setting that moves it to the strict end. The fix really is one toggle. This post is mostly a nudge to go and flip it.
Simon Morley researches infrastructure security and is the founder of NullRabbit. About / contact.
Related Posts
How we hunt request smuggling without breaking anything
A timing hunch is not a finding. The discipline that separates real desync research from noise is the part nobody photographs: a lab of real proxies, a back-end you own that logs the literal forwarded bytes, and a hard line about who you're allowed to point any of it at.
Meet Keith, and why we're keeping it closed
We built our own HTTP engine from scratch. No normalisation, no typed header map, no helpfulness at all, because a well-behaved client quietly fixes the exact malformations you need to send. Here is what Keith is, and why we changed our minds about open-sourcing it.
What we build when we're not looking at validators
The method we built for blockchain validator security turns out to be a general-purpose bug-finding method. We've started pointing it at two pieces of infrastructure everyone shares: the open-source HTTP proxy ecosystem, and the Linux kernel's packet path.
