# CVE-2026-14198 — Root Cause Analysis

## Summary

`@fastify/middie` versions 9.1.0 through 9.3.2 decode the percent-encoded slash `%2F`
inside path-parameter values **before** matching middleware paths, while Fastify's
underlying router (`find-my-way`) preserves the encoding during route lookup. The two
layers therefore disagree on the canonical request path: middie normalizes
`/user/a%2Fb/comments` to `/user/a/b/comments` (which no longer matches the guard
`/user/:id/comments`), but the router still matches the route and dispatches to the
handler. The result is an HTTP-method-agnostic authentication / authorization bypass:
an unauthenticated attacker reaches a protected handler on a parameterized path by
embedding an encoded slash in the parameter position.

## Impact

- **Package / component affected:** `@fastify/middie` (`lib/engine.js`,
  `normalizePathForMatching`).
- **Affected versions:** 9.1.0 – 9.3.2 (confirmed against 9.3.2; fixed in 9.3.3).
- **Risk level:** Critical. Any application that uses middie middleware for
  authentication, authorization, rate limiting, or auditing on a **parameterized**
  path (e.g. `/api/:resource`, `/user/:id/comments`) can have that guard silently
  bypassed with a single crafted URL. No authentication or preconditions are required
  and the bypass is HTTP-method agnostic.

## Impact Parity

- **Disclosed / claimed maximum impact:** Authentication/authorization bypass on
  parameterized middleware paths; an attacker reaches a protected handler without
  credentials.
- **Reproduced impact from this run:** A real Fastify + middie server with an
  API-key auth guard on `/user/:id/comments` returns **200 `{"ok":true,"id":"a/b"}`**
  for an unauthenticated request to `/user/a%2Fb/comments` — the guard is bypassed and
  the protected handler executes. The same request against the fixed build returns
  **401 Unauthorized**.
- **Parity:** `full`. The exact claimed bypass (unauthenticated reach of a protected
  parameterized-path handler via an encoded slash) was demonstrated through both the
  Fastify `app.inject` library entrypoint and a real `127.0.0.1` HTTP server.
- **Not demonstrated:** This is an authorization/authn bypass, not memory corruption
  or code execution; no crash or RCE is claimed or reproduced.

## Root Cause

In `lib/engine.js`, every request is normalized for middleware matching via
`normalizePathForMatching(url, options)`. In the vulnerable version that function calls:

```js
path = FindMyWay.sanitizeUrlPath(path, options.useSemicolonDelimiter)
```

`sanitizeUrlPath` **decodes** percent-encoded characters, so `%2F` becomes a literal
`/`. When a guard is registered on a parameterized prefix such as `/user/:id/comments`
(compiled with `path-to-regexp`, `end:false`), the decoded path
`/user/a/b/comments` has an extra segment and **fails to match** the guard's regexp.
middie therefore runs zero middleware and Fastify's router — which keeps `%2F` encoded
during lookup — still matches the route `/user/:id/comments` (with `id = "a/b"`) and
dispatches the handler. The guard is skipped.

The fix (commit `61d90cd`, "fix(engine): preserve encoded slashes in middleware params",
released as 9.3.3) replaces the decoder with find-my-way's safe decoder that **preserves
reserved characters** such as `%2F`:

```js
const { safeDecodeURI } = require('find-my-way/lib/url-sanitizer')
...
path = safeDecodeURI(path, options.useSemicolonDelimiter).path
path = decodeNestedPercentEncodedBytes(path)   // %25xx -> %xx only
```

With `safeDecodeURI`, `/user/a%2Fb/comments` stays `/user/a%2Fb/comments` for
middleware matching, which matches `/user/:id/comments`, so the guard runs and blocks
unauthenticated requests (401). Ordinary percent-encoded bytes and nested
`%25xx` encodings remain compatible with previous matching behavior, and the malformed
percent-encoding 400 handling is preserved.

- **Fix commit:** `61d90cd0f578367283b486cb95f3b8c14bf3ddbf`
  ("fix(engine): preserve encoded slashes in middleware params", v9.3.3).
- **Advisory ref:** GHSA-2v46-jxjm-7q3v.

## Reproduction Steps

1. The self-contained script is `bundle/repro/reproduction_steps.sh`. It:
   - Reads `bundle/project_cache_context.json` and reuses the prepared project cache
     (`repo-vuln-v932` = 9.3.2 vulnerable, `repo` = 9.3.3 fixed), with an `npm install`
     fallback to `@fastify/middie@9.3.2` / `@fastify/middie@9.3.3` if the cache is absent.
   - Registers a Fastify app with middie, an API-key auth guard on the parameterized
     middleware path `/user/:id/comments`, and a protected handler on the same pattern.
   - Exercises the **library_api** entrypoint via `app.inject` and a **real HTTP
     server** on `127.0.0.1` (raw node http client that preserves `%2F`) for both the
     vulnerable and the fixed build.
   - Asserts: vulnerable bypass → 200 (handler reached, guard bypassed); fixed bypass
     → 401 (guard matches); baseline → 401; allowed (with key) → 200 for both.
2. Expected evidence: the vulnerable build returns `200 {"ok":true,"id":"a/b"}` for the
   unauthenticated `/user/a%2Fb/comments` request, while the fixed build returns
   `401 {"error":"Unauthorized"}`. A clean divergence proves the bypass and the patch.

## Evidence

- **Master log:** `bundle/logs/reproduction_steps.log`
- **Inject harness results:** `bundle/artifacts/inject_vuln.json`,
  `bundle/artifacts/inject_fixed.json` (and `bundle/logs/inject_vuln.log`,
  `bundle/logs/inject_fixed.log`)
- **Real HTTP server evidence:**
  - `bundle/artifacts/http/vuln/server.log`, `bundle/artifacts/http/vuln/responses.txt`
  - `bundle/artifacts/http/fixed/server.log`, `bundle/artifacts/http/fixed/responses.txt`
- **Runtime manifest:** `bundle/repro/runtime_manifest.json`

Key excerpts (real HTTP server, raw node client preserving `%2F`):

```
=== vulnerable-server /user/a%2Fb/comments (NO api key) ===
STATUS:200
{"ok":true,"id":"a/b"}        <-- guard bypassed, protected handler reached

=== fixed-server /user/a%2Fb/comments (NO api key) ===
STATUS:401
{"error":"Unauthorized"}      <-- guard now matches and blocks

=== both builds baseline /user/alice/comments (NO api key) ===
STATUS:401  {"error":"Unauthorized"}   <-- guard works for normal paths

=== both builds /user/a%2Fb/comments (WITH api key) ===
STATUS:200  {"ok":true,"id":"a/b"}     <-- route still matches when allowed
```

Result summary from the script:

```
inject  vuln:  baseline=401 bypass=200 allowed=200
inject  fixed: baseline=401 bypass=401 allowed=200
server  vuln:  baseline=401 bypass=200
server  fixed: baseline=401 bypass=401
```

Environment: Node.js v24.18.0, `@fastify/middie` 9.3.2 (vulnerable) and 9.3.3 (fixed),
Fastify from each workspace's `node_modules`. The vulnerable `lib/engine.js` uses
`FindMyWay.sanitizeUrlPath` (decodes `%2F`); the fixed `lib/engine.js` uses
`safeDecodeURI` (preserves `%2F`).

## Recommendations / Next Steps

- **Upgrade** to `@fastify/middie@9.3.3` or later immediately. The fix preserves
  encoded slashes in middleware matching so parameterized guards can no longer be
  bypassed.
- **Audit** existing middleware registrations: any guard on a parameterized path
  (`/:param`, `/api/:resource`, `/user/:id/...`) used for authn/authz/rate-limiting is
  a candidate bypass surface on vulnerable versions.
- **Defense in depth:** do not rely solely on middleware for authorization; also
  enforce authorization inside route handlers, and normalize/reject encoded slashes at
  the edge where appropriate.
- **Regression test:** the upstream fix ships
  `test/security-encoded-slash-param-bypass.test.js`; keep it in CI. Add cases for
  additional encodings (`%2f` lower-case, double-encoded `%252F`) and method-agnostic
  checks (POST/PUT/DELETE).

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` was executed twice consecutively; both runs
  exited 0 with identical results (vulnerable bypass=200, fixed bypass=401). Servers
  are started on fixed localhost ports and torn down via `trap`/`SIGTERM`, so repeated
  runs are clean.
- **Two surfaces, one bug:** the bypass is demonstrated both through the canonical
  library entrypoint (`app.inject`, classified as `library_api` to match the submitted
  claim surface) and over a real `127.0.0.1` TCP socket with a raw node http client
  that preserves `%2F` (curl `--path-as-is` was also verified to preserve `%2F`).
- **Limitations / edge cases:** the bypass requires the guard to be registered on a
  *parameterized* path; a static-prefix guard (e.g. `/api`) is not bypassed by this
  specific vector. Lower-case `%2f` is equivalent to `%2F` for the decoder and is
  bypassed the same way. The malformed-percent (`/%zz`) 400 handling is preserved by
  the fix.
