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.

REQUEST GET /public/..%2fadmin/users nginx MATCHES AGAINST /public/..%2fadmin/users no /admin/ prefix — ALLOW PROXIED AS-IS Tomcat DECODES, THEN ROUTES /admin/users admin route reached DIVERGENCE the authorisation decision was made about a path that no longer exists by the time the handler runs
Both components behave as documented. nginx never percent-decodes before location matching; the container decodes and resolves before routing. The bypass lives in the gap.

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

SignalWhat it provesNegative controlDefender verification
Proxy log preserves an encoded path that the application resolves differentlyTwo security-relevant parsers are authorizing different representationsSend the canonical protected path and confirm the proxy blocks itCapture raw request target and final application route in the same trace
Mutated path reaches the protected handler without the expected identityThe parser disagreement crosses an authorization boundarySend a nearby mutation that normalizes identically on both layers and expect denialAdd integration tests at the deployed proxy/application pair, not only unit tests per component
One canonicalization pass before policy evaluation closes all equivalent variantsThe fix addresses the representation class rather than one payloadRe-run double encoding, separators, dot segments, and case variantsCompare normalized path bytes used by policy and routing code
Direct application access is not externally reachableThe proxy remains the only intended enforcement entry pointAttempt the same request against the backend from an untrusted segment and expect no routeVerify 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.

Sources & freshness

How current is this note?

Sources checkedJanuary 16, 2025

The latest source-review, content-update, or publication date is shown.

ReviewAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.