BrickellTechnologies

Research

When your router and your middleware read different URLs

Elias Hasas · ·7 min read

Here is the whole bug.

GET /private/secrets HTTP/1.1     ->  401 denied
GET http://anything/private/secrets HTTP/1.1  ->  200 {"secret":"the-flag"}

Same server, same route, same middleware sitting in front of it. The second request reaches the handler without the authentication middleware ever running.

That is CVE-2026-85184, CVSS 9.1, in @fastify/middie. We reported it privately on 2 September and Matteo Collina had a fix out on 4 September. If you run Fastify with middie, the version you want is 9.3.4.

Are you affected

You are if all of these are true.

You have @fastify/middie at 9.1.0 or later and below 9.3.4:

npm ls @fastify/middie

And you use it for path-scoped middleware, meaning you pass a path as the first argument:

app.use('/private', requireAuth)      // affected
app.use(requireAuth)                  // not affected, this is global

Global middleware still runs. So do Fastify’s own hooks, preHandler and the rest, because those fire after the router has already resolved the path. The bug lives specifically in the gap between the router and middie’s path matching, so only the thing that does its own matching gets it wrong.

If you are on a supported version and you use path-scoped middleware for authentication, authorization or rate limiting, treat this as urgent. No credentials are needed, no unusual configuration, and the request is a single line.

What is actually going on

An HTTP request line can carry its target in more than one shape. The one everybody sees is origin-form:

GET /private/secrets HTTP/1.1
Host: example.com

But RFC 9112 §3.2.2 also defines absolute-form, where the whole URL goes on the request line:

GET http://anything/private/secrets HTTP/1.1
Host: x

Absolute-form exists mainly because proxies need it, and the spec requires servers to accept it regardless. Fastify’s router does the right thing: it takes that target, extracts the path component, gets /private/secrets, and dispatches the route.

Middie also normalises the path before deciding which middleware to run. Its version of that normalisation skipped the extraction step. So while the router was matching /private/secrets, middie was testing the literal string http://anything/private/secrets against the pattern /private, finding no match, and calling next().

Two components, one request, two different answers to the question “what is the path”. The handler runs. The guard does not.

The part worth more than the bug

This is the fifth path-parsing bypass fixed in middie during 2026. The earlier ones involved encoded slashes, child plugin scopes, a deprecated duplicate-slash option, and path matching more generally. Different inputs, identical shape: something normalises a path one way, something else normalises it another way, and a request slips through the gap.

That pattern is not really a middie problem, and it is worth naming properly because it will turn up in your own stack. Any time two components independently parse the same attacker-controlled string and then make a decision based on their own answer, the difference between those answers is an exploit. It does not matter how careful either parser is on its own. What matters is that there are two of them.

The same shape produces web cache poisoning, request smuggling, and a good share of WAF bypasses. We spend a lot of our research time on exactly this class, because it is systematically underexplored: the individual components are usually fine, and the bug only exists in the space between them.

Which suggests a defensive rule that holds up well: decide once, downstream of the thing that resolves the ambiguity. In Fastify terms, that means authentication belongs in a hook, where the path has already been settled by the router, rather than in a layer doing its own matching upstream. That is also the workaround the advisory recommends, and it is worth doing even after you have upgraded, because it removes the whole class rather than this instance of it.

Fixing it

Upgrade:

npm install @fastify/middie@^9.3.4

If you cannot upgrade today, move the affected checks into a Fastify hook:

app.addHook('preHandler', async (req, reply) => {
  if (req.url.startsWith('/private')) await requireAuth(req, reply)
})

That runs after routing, so it sees the resolved path either way.

Then go and look for the same shape elsewhere. If you have a reverse proxy making routing decisions in front of an application that parses paths again, or a CDN with path-based rules, or a WAF matching on URL patterns, you have the same architecture. Try an absolute-form request against those too. It takes about a minute with curl --request-target, and it is a fair question to ask of anything sitting between the internet and your handlers.

Timeline

  • 2 September reported privately through GitHub Security Advisories
  • 4 September, 08:09 UTC advisory published and 9.3.4 released
  • 4 September, 09:15 UTC CVE-2026-85184 assigned by the OpenJS Foundation CNA
  • 4 September, 10:17 UTC published to NVD

Two days from report to fix on a Critical, in a volunteer-maintained package, with a workaround documented in the advisory for anyone who could not upgrade immediately. Credit to Matteo Collina for the patch and Ulises Gascón for reviewing it. That is a maintainer response most commercial vendors would struggle to match, and it deserves saying plainly.

Sources

If you run Fastify and want someone to go looking for this class in your own stack, that is a penetration test question, and if the boundary you are worried about is a pipeline or an AI agent doing its own parsing, we cover those too.

Know what an attacker would reach first.

Tell us what you run and what worries you. We will come back with a scope, a fixed price and the earliest week we can start.