Lion Cipher Lab
Break and fix path-based authorization with parser differentials
- AppSec / DevSecOps
- Advanced
- ~2 hours
- Free · no account required
Why it matters
Almost every production web system enforces authorization at least partly on the URL path: an ingress rule that says /admin/* requires a role, a WAF signature that blocks /internal, an API gateway route that demands a scope for /v1/billing, an nginx 'location' block with a deny. It is the cheapest control in the building and it is deployed everywhere, which is exactly why it is worth understanding that it rests on an assumption almost nobody checks: that the component making the decision and the component serving the response agree on what the path IS. They usually do not. A URL path is not a…
What you'll learn
- the parser-differential bug class (CWE-436): two components in one request path that resolve the SAME URL differently, so a policy decided by one is enforced against a different resource by the other
- implementing RFC 3986 section 5.2.4 remove_dot_segments literally, from scratch, and proving that split-and-pop, posixpath.normpath and os.path.normpath all disagree with it and with each other on real inputs
- the decode-ORDER axis: decode-then-normalize vs normalize-then-decode vs decode-twice, measured against %2e%2e%2f, %252e%252e%252f, ..%00/, ..%01/ and an overlong %c0%af
- building a reverse-proxy gateway and an origin app from scratch on http.server + raw sockets, so you can send a byte-exact request target that urllib and requests would have silently rewritten
- four named failure modes with numbers: allowlist-by-prefix vs canonical form; check-on-RAW while the router reads DECODED; the proxy that strips a prefix so the two halves disagree about what the path even is; and a '..' denylist regex that one re-encoding defeats
- the availability half of the same bug -- FALSE BLOCKS -- and why /admin/login and /admin/health being 403 for everyone is the same defect as the bypass, not a separate one
- canonicalize exactly once at the edge and REJECT with 400 rather than silently repairing, and measuring exactly how far that gets you (and where it still fails)
- the strongest fix: do not authorize on the path at all -- authorize on the resolved route identity inside the app, after routing, so only one parser exists
- recognising the hidden third parser: your web server, CDN, WAF and framework each normalize before your code sees the request, and CPython's own http.server rewrites a leading '//' (gh-87389)
- the review reflex: every time a policy names a path prefix and something downstream resolves that path again, ask which algorithm each one uses, and never assume they match
Steps in this lab
- The bug in one sentence: two parsers, one path
- Set up: one folder, two files, nothing to install
- Create pathauthz.py -- four normalizers, one gateway, one origin app
- Create harness.py -- the rig that starts both processes and counts
- Run it once and read the headline
- Section [N]: four normalizers, and none of them agree
- Section [D]: the decode-ORDER axis -- same resolver, different answer
- Section [1]: what each half thinks the path is, before a single packet moves
- Section [1b]: the third parser you did not know you had
- Section [2]: live, through two processes -- 15 of 19 get in
- Section [3]: the half nobody counts -- false blocks and locked-out users
- Failure mode (a): allowlist-by-prefix versus the canonical form
- Failure mode (b): the check reads the RAW target, the router reads the DECODED path
- Failure mode (c): the proxy strips a prefix, so the halves disagree about what the path IS
- Failure mode (d): a denylist regex for '..' that one re-encoding defeats
- Fix 1: canonicalize exactly once at the edge, and REJECT rather than repair
- Fix 2: do not authorize on the path -- authorize on the resolved route, after routing
- Judgment: why this fix and not that one, and when not to bother
- Find it in your own systems, and what a reviewer looks for