Path-normalisation bypasses in 60 seconds
There is a category of authentication bypass where you can read both components, find nothing wrong with either, and still walk into the admin panel. The proxy config is correct. The application’s access control is correct. The bug is that they are looking at two different strings.
Two parsers, one URL
Almost every deployment splits the request across at least two pieces of software: something in front that terminates TLS and makes routing decisions, and something behind that actually serves the application. Both of them parse the path. They do not have to agree.
The front-end decides whether you are allowed. The back-end decides what runs. If the front-end matches on a string the back-end later transforms, the decision was made about a path that no longer exists by the time the handler executes.
That is the whole class. Everything else is which transformation you can reach.
A lab that shows it
Minimal setup: nginx in front, a Tomcat-backed application behind it. The proxy denies the admin tree the obvious way.
location /admin/ {
return 403;
}
location / {
proxy_pass http://127.0.0.1:8080;
}
Direct request, blocked as designed:
$ curl -si http://localhost/admin/users | head -1
HTTP/1.1 403 Forbidden
Now the same destination, spelled differently:
$ curl -si http://localhost/public/..%2fadmin/users | head -1
HTTP/1.1 200 OK
Nothing is wrong with nginx here. It normalises the URI before location matching — it will
resolve a literal /../ and collapse duplicate slashes — but it does not percent-decode
before matching. /public/..%2fadmin/users does not begin with /admin/, so the deny block
never applies, and the request is proxied through with the encoding intact.
Tomcat receives it, decodes %2f to /, resolves the .. segment, and dispatches:
[http-nio-8080-exec-3] DispatcherServlet : GET "/admin/users", parameters={}
Two components, each behaving as documented, combining into an unauthenticated read of the admin tree.
The transformations worth trying
The encoded slash is the well-known one. It is rarely the only one available, and in a hardened environment it is usually the first one closed. The others come from the same place — a difference in what counts as “the same path”:
Path parameters. Servlet containers strip ;key=value segments. A proxy matching the
literal string sees /admin;x=1/users as unrelated to /admin/; Tomcat sees the admin
route.
Double encoding. If anything in the chain decodes twice — a proxy that decodes once
before forwarding, plus a container that decodes again — then %252f survives the first
pass as %2f and becomes / on the second.
Case. Front-end location matching is usually case-sensitive. A back-end on a
case-insensitive filesystem is not. /Admin/users is a different string to one and the same
route to the other.
Trailing forms. /admin versus /admin/ versus /admin/. versus /admin%2e. A rule
written for one form frequently misses the others, and frameworks routinely canonicalise all
four to the same handler.
The productive way to test this is not to run a payload list. It is to establish, for one known-good path, exactly which transformations survive the front-end and which the back-end applies — and then look for a rule whose match depends on a transformation that only one side performs.
Evidence matrix
| Signal | What it proves | Negative control | Defender verification |
|---|---|---|---|
| Proxy log preserves an encoded path that the application resolves differently | Two security-relevant parsers are authorizing different representations | Send the canonical protected path and confirm the proxy blocks it | Capture raw request target and final application route in the same trace |
| Mutated path reaches the protected handler without the expected identity | The parser disagreement crosses an authorization boundary | Send a nearby mutation that normalizes identically on both layers and expect denial | Add integration tests at the deployed proxy/application pair, not only unit tests per component |
| One canonicalization pass before policy evaluation closes all equivalent variants | The fix addresses the representation class rather than one payload | Re-run double encoding, separators, dot segments, and case variants | Compare normalized path bytes used by policy and routing code |
| Direct application access is not externally reachable | The proxy remains the only intended enforcement entry point | Attempt the same request against the backend from an untrusted segment and expect no route | Verify network policy and listener exposure alongside parser behavior |
The pattern I keep seeing
This shows up wherever authorisation was bolted on at the edge because it was easier than changing the application. A path-prefix rule in a proxy, a WAF pattern, a gateway route matcher — anything that makes an allow/deny decision by comparing a URL to a string, at a different layer from the code that resolves that URL.
The same two-parser divergence is what breaks OAuth redirect validation — the provider authorises one URL, the browser fetches another — as the OAuth note walks through. It is one bug class wearing many hats, and the fix is the same in all of them.
It is also why the finding tends to be reported at the wrong severity. It gets written up as one bypassed endpoint, which invites a one-line fix to one rule. But the endpoint was never the problem. The problem is that the authorisation boundary sits somewhere that cannot see the same request the application sees, and there is usually more than one way to exploit that gap.
Why “block the encoding” is the wrong remediation
The reflex fix is to reject %2f at the edge. It closes the request you demonstrated. It
does not close the class, because the next transformation is still available and the
authorisation decision is still being made on a string that the back-end will rewrite.
Two remediations actually hold:
Move the decision behind the transformation. Enforce authorisation in the application, after the framework has resolved the route, against the handler it actually chose — not against the raw path. Proxy rules become defence in depth instead of the control.
Make the two parsers agree. If a front-end rule has to stay authoritative, normalise fully and identically before it runs — decode once, resolve relative segments, strip path parameters, fold case if the back-end does — and reject anything that still changes shape after normalisation rather than passing it on.
The second is harder than it looks and worth saying plainly in a report: keeping two parsers in agreement forever is a maintenance commitment, and the first option removes the need for it.
What to hand the defenders
The useful artefact is not the one working payload. It is the pair of observations that
explain why it worked: here is the string the proxy matched against, here is the string the
application dispatched on, and here is where they diverged. With those two lines, the fix is
obvious to whoever owns the stack. Without them, you get an edge rule patched for %2f and
the same finding again next year in a different spelling.
How current is this note?
The latest source-review, content-update, or publication date is shown.
The author completed a technical review. This does not, by itself, claim lab reproduction.
