Path Normalisation Differentials: /admin Bypass and Cache Deception
A payments company protects portal.example.com with Cloudflare. The origin is a Spring Boot application behind an AWS ALB, and /admin is the internal console. A custom WAF rule blocks any request whose path contains /admin. During a review, a consultant demonstrates that /.;/admin/, //admin/ and /admin;jsessionid=x/ are not blocked by the rule, and that the application still routes at least two of them to the console servlet. Separately, the company added a cache rule last quarter: 'Cache eligible if the path ends in .css, .js or .png'. A tester shows that GET /account/statements/2024-11.css, requested while authenticated, returns that user's statement content, and Cloudflare serves the stored response to a second, unauthenticated client for the next 40 minutes. Neither issue generated an alert. The CISO asks whether the WAF rule should be extended with more path patterns.
1. Explain the root cause shared by both findings, and why adding more path patterns to the block rule is the wrong fix.
Read the worked answer
Both are parser-differential bugs: two components disagree about what a string means, and the security decision is made by a component that is not the one enforcing access.
For /admin: the edge evaluates the literal path string, while the application server collapses //, resolves . and .. segments, and strips ; path parameters before routing. The edge therefore sees a path that does not contain /admin, and the origin routes to the console servlet anyway. The mapping from string to endpoint is many-to-one; a substring blocklist can only enumerate a handful of the many.
For cache deception: the cache rule classified 'static' from the path suffix, while the origin classified 'authenticated dynamic statement' from the route /account/statements/{id}. Two classifiers, two answers, and the cache layer - which sits in front of the origin's authorisation - won.
Adding more patterns is the wrong fix because the set of equivalent encodings is unbounded: .;/, %2f, %252f, ;jsessionid=, mixed case, Unicode normalisation, and whatever the next framework patch changes. You are adding enumerations to an infinite list and still making the decision at the wrong layer. The fix is to enforce at the layer that resolves the request, and to make each rule default-deny rather than default-allow. More patterns also increase the false-positive risk on a ruleset that currently has none, which is its own failure.
2. What do you change at the edge and at the origin for the /admin bypass, and how do you verify the fix?
Read the worked answer
Origin (the actual control): /admin must require authentication and authorisation server-side, and should not be internet-reachable at all. Put it behind Cloudflare Access or a VPN so the console needs an identity assertion, not a path rule. Harden the routing layer so the ambiguity disappears: reject requests whose raw path differs from its canonical form, and reject encoded slashes, backslashes and ; path parameters rather than silently normalising them.
Edge (defence in depth): keep a rule, but express it as default-deny on the admin hostname rather than a substring - if a request reaches admin.portal.example.com without a valid Access JWT, deny. For portal.example.com, test what Cloudflare actually presents in http.request.uri.path for each payload first, using logs, then write rules against that observed form rather than against the raw client string. Guessing at edge normalisation is how rules like this fail silently.
Verification: replay the consultant's full payload list through both layers and record the status code at each. A 403 from the WAF and a 404 or 401 from the origin are both acceptable outcomes. A 200 from the console is not. Then disable the edge rule deliberately in staging and confirm the origin still refuses - that is the only test that proves the bypass is closed rather than hidden. Add an alert on any 200 response from an /admin route, since nothing currently detects this.
3. Fix the cache deception issue, and explain the mechanics of how the second client received another user's data.
Read the worked answer
Mechanics. The cache rule classified the request as static because it ended in .css; the origin classified it as the authenticated route /account/statements/{id} and returned the statement. The edge stored the response under a key of URL only - no Vary on the session cookie, because Cloudflare does not cache HTML by default and the custom rule overrode that default. The second client requested the same URL, got a cache hit, and never caused an origin call, so the origin's authorisation was never consulted. There is no flaw in the origin's auth; the cache simply sits in front of it. Forty minutes is the TTL the rule set.
Fix.
- Stop using the path suffix as the eligibility signal. Scope the cache rule to structurally static paths (
/assets/,/static/, versioned filenames) and to response content type, not extension. - Add negative conditions: never cache under
/account/,/api/or/admin/, and never cache a request carrying a session cookie. The cache rule expression can testhttp.cookieandhttp.request.uri.path. - At the origin, send
Cache-Control: private, no-storeon every authenticated response - defence in depth for the next misconfigured rule. - Purge the affected URLs immediately and treat it as a data exposure: identify which cached objects were served and to whom, then notify.
Test after: request the .css-suffixed statement URL with and without a session and confirm cf-cache-status reports BYPASS or DYNAMIC, not HIT.