---
title: "The Path Your Proxy Blocks Is Not the Path Your App Runs"
description: "When authorisation and routing are done by different software, they parse the URL differently. The bypass is not in either component — it is in the order they run."
date: 2025-01-16
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/posts/path-normalisation-auth-bypass/
---

## 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.

<figure class="diagram">
<svg viewBox="0 0 700 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The same request seen by two parsers: nginx matches the still-encoded path and allows it; Tomcat decodes it and dispatches to the admin route.">
<defs>
<marker id="pn-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker>
</defs>
<text x="0" y="14" class="dg-muted">REQUEST</text>
<text x="0" y="34" class="dg-mono">GET /public/..%2fadmin/users</text>
<line x1="110" y1="46" x2="110" y2="72" class="dg-line" marker-end="url(#pn-a)" />
<rect x="0" y="78" width="300" height="88" rx="8" class="dg-box" />
<text x="16" y="100" class="dg-label">nginx</text>
<text x="16" y="121" class="dg-muted">MATCHES AGAINST</text>
<text x="16" y="139" class="dg-mono">/public/..%2fadmin/users</text>
<text x="16" y="157" class="dg-accent">no /admin/ prefix — ALLOW</text>
<line x1="302" y1="122" x2="396" y2="122" class="dg-line dg-dash" marker-end="url(#pn-a)" />
<text x="322" y="112" class="dg-muted">PROXIED</text>
<text x="326" y="139" class="dg-muted">AS-IS</text>
<rect x="400" y="78" width="300" height="88" rx="8" class="dg-box-accent" />
<text x="416" y="100" class="dg-label">Tomcat</text>
<text x="416" y="121" class="dg-muted">DECODES, THEN ROUTES</text>
<text x="416" y="139" class="dg-mono">/admin/users</text>
<text x="416" y="157" class="dg-crit">admin route reached</text>
<line x1="0" y1="198" x2="700" y2="198" class="dg-line dg-dash" />
<text x="0" y="222" class="dg-crit">DIVERGENCE</text>
<text x="92" y="222" class="dg-mono">the authorisation decision was made about a path that no longer</text>
<text x="92" y="240" class="dg-mono">exists by the time the handler runs</text>
</svg>
<figcaption>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.</figcaption>
</figure>

## 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.

```nginx
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](/posts/oauth-flow-abuse/) 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.