Home/Journal/Teardown
TeardownCriticalauthenticationpath-normalizationmiddleware

A Case-Sensitive Lock on a Case-Insensitive Door

A case-sensitive auth middleware and a case-insensitive router disagreed, so an uppercase admin path skipped authentication entirely. Here is how, and the fix.

Rahul Dharan··8 min read·a coliving operatorAuthentication bypass via path normalization (CWE-287)

The setup

The target was a coliving and student-housing operator: a company that rents managed shared living to residents and runs the whole relationship through its own platform. That means the platform holds exactly the data you would expect a landlord and a payment processor to hold at once - identity documents, bank details for rent collection, and the records that tie a real person to a room.

Concentrated resident PII behind a single admin plane is what made this worth a careful look. When one product both onboards a tenant and moves their money, the administrative side of that product is a high-value surface, and how it decides who is allowed in is the question that matters most.

Recon

Working black-box, from the open internet with no account, we mapped the platform’s API. Public endpoints answered normally. The administrative endpoints answered too, but with a 401 Unauthorized, which is the correct behaviour: the admin API existed, it was reachable, and it was asking for authentication. On the surface, the door was locked.

That is usually where an admin surface either holds or does not. We were interested in one narrow question: was the lock enforced by the same logic that decided where the request actually went?

The hypothesis

Modern web stacks split two jobs that feel like one. Authentication middleware decides whether a request is allowed to proceed, usually by matching the request path against a list of protected prefixes. The router then decides which handler actually serves the request, by matching the path against its route table. These are two separate pieces of code, and they do not have to agree on how they read a path.

One common disagreement is letter case. Middleware that matches protected paths often does so case-sensitively - a plain string comparison against /admin. Routers, on many frameworks, resolve routes case-insensitively, so /admin, /Admin, and /ADMIN all reach the same handler.

The hypothesis followed directly. If the auth middleware only recognises the lowercase admin prefix, but the router happily resolves an uppercase variant, then an uppercase request skips the guard and still hits the protected handler. The lock is case-sensitive; the door is case-insensitive.

What we tried

We requested an admin endpoint the normal way and got the expected 401. The middleware was doing its job for the path it recognised.

Then we sent the same request with the admin segment in uppercase. The auth middleware, matching case-sensitively, did not recognise the path as protected and let it through unchecked. The router, resolving case-insensitively, sent it to the very same admin handler. The response came back 200 OK, with data, and no authentication had been required to get it.

Once the bypass held for one endpoint, we checked whether it was the whole admin API or a single stray route. It was the whole plane. Every administrative endpoint we tried answered to the uppercase variant with no credentials. The guard and the router disagreed everywhere the guard was supposed to protect.

The restraint here mattered as much as the finding. When we confirmed the impact, we did it by reading counts and headers, not by pulling data. Records were counted through the endpoint’s own response metadata, never downloaded. The document store behind it was confirmed with a single header check on one object, nothing more. The goal was to prove that the admin plane was open and what sat behind it, not to exfiltrate a tenant’s identity documents to make the point.

What we found

The bypass was total, and what it exposed set the severity.

One administrative endpoint returned a bulk tenant dump: thousands of resident records in a single authenticated-looking call that required no authentication. The records included bank account and routing details for rent collection, and links to KYC identity-document scans.

-> GET /ADMIN/<tenant-listing>        (uppercase segment)

   (no Authorization header)

<- 200 OK
   { "total": <thousands>, "tenants": [
       { "name": "...", "bank_account": "...", "routing": "...",
         "kyc_doc": "https://<storage>/<id>" },
       ... ] }

Those KYC links pointed at a storage bucket holding the raw identity-document scans. A single header check on one object confirmed the bucket served them. So the chain ran from an uppercase path, to an unauthenticated bulk read of resident banking and identity data, to the underlying scans themselves - a full-estate exposure of exactly the data a coliving operator is trusted to protect, reachable by anyone who changed the case of a URL.

Why it happens

Nobody wrote insecure code on purpose. This is what happens when two correct components hold slightly different definitions of the same thing.

The auth middleware was doing precisely what it was told: protect the paths on this list. The router was doing precisely what its framework does: resolve routes without caring about case, which is a convenience users expect. Each is reasonable alone. The vulnerability lives in the seam, where the guard is stricter than the thing it guards. Because the middleware matched an exact lowercase string and the router matched loosely, there was a set of paths - every case variant except the one on the list - that the router would serve but the guard would not inspect. The lock was real. It was just guarding a narrower doorway than the router opened.

This is easy to ship and hard to notice, because the normal path behaves perfectly. Every test a developer runs by hand uses the lowercase URL, gets the 401, and confirms the admin API is protected. The bypass only appears when someone asks the router a question the guard was not written to expect.

For developers

If your stack separates authentication from routing, these controls close this:

  1. Canonicalize the path once, before both matching and routing. Normalize case, trailing slashes, encoded characters, and duplicate separators in one place, up front, and hand the same canonical path to the auth layer and the router. They must never normalize independently.
  2. Make the auth layer and the router agree exactly. If the router is case-insensitive, the guard must be case-insensitive against the same rules. Any difference in how the two read a path is a potential bypass, not just for case.
  3. Default-deny admin routes. Do not protect admin by enumerating a prefix to guard. Deny everything under the admin namespace unless a request is positively authenticated and authorized, so an unrecognised path variant fails closed instead of open.
  4. Never key authentication on an exact string the router treats loosely. If your authorization decision is a string comparison, it must be at least as permissive in what it recognises as the router is in what it serves - otherwise the router serves paths the guard never sees.

And one detection step: log the raw request path alongside the resolved route and alert when a request reaches an admin handler without having passed the admin auth check. That discrepancy is the bypass, recorded.

The takeaway

An authentication check only protects the exact requests it recognises. When the guard and the router disagree about what a path is - here, one case-sensitive, one case-insensitive - the router will faithfully serve a request the guard never inspected, and the whole admin plane goes with it. Normalize once, match identically, and default-deny. This teardown is one instance of a pattern we see repeatedly, written up in full in The Sibling Service That Skipped Authentication.

Frequently asked

What is an authentication bypass via path normalization?
It is when the component that enforces authentication and the component that routes the request normalize the URL path differently. A path variation, such as a change of letter case, can miss the auth check while still reaching the protected handler, so the request runs with no authentication.
How does a case-sensitivity mismatch cause an auth bypass?
If auth middleware matches protected paths case-sensitively but the router resolves paths case-insensitively, requesting the path in a different case slips past the middleware yet still routes to the same handler. The guard is stricter than the router, so a case variant is unauthenticated but still executes.
How do you prevent path-normalization auth bypasses?
Canonicalize the path once before both matching and routing so the auth layer and the router agree exactly, default-deny admin routes, and never key authentication on an exact string match that the router treats loosely.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →