# CVE-2026-14198 — Variant / Bypass Analysis (Root Cause Analysis)

## Summary

No distinct variant or bypass of the `@fastify/middie` 9.3.3 fix was found. The
CVE-2026-14198 bypass (an unauthenticated attacker reaches a handler guarded by a
**parameterized** middie middleware path by embedding an encoded slash `%2F` in the
parameter position) was reproducible on the vulnerable 9.3.2 build across **24**
distinct request shapes, but **zero** of those shapes — nor any of the additional
encoding, structural, router-option, HTTP-method, or encapsulated-prefix entry-point
variants tested — bypassed the fixed 9.3.3 build. The fix (commit `61d90cd`,
"fix(engine): preserve encoded slashes in middleware params") aligns middie's
middleware-matching path decoder with Fastify's router decoder by switching from
`FindMyWay.sanitizeUrlPath` (which decoded `%2F`→`/`) to find-my-way's
`safeDecodeURI` (which preserves `%2F`), and adds a single-level
`decodeNestedPercentEncodedBytes` (`%25XX`→`%XX`) that can never re-introduce a
literal slash. Because there is only **one** path-decode sink in the codebase and the
fix replaced it wholesale, no alternate entry point or data path reaches a different
decoder. This is a **negative variant result**: the fix is complete for the
encoded-slash-in-parameter bypass class.

## Fix Coverage / Assumptions

**Invariant the fix relies on:** the canonical request path used for middleware
matching must agree with the canonical request path used by Fastify's router
(`find-my-way`) for every input, so that a parameterized guard regexp compiled by
`path-to-regexp` matches iff the router dispatches the route. Concretely, reserved
path characters — above all the segment-breaking `/` (`%2F`/`%2f`) — must stay
**encoded** in middleware matching exactly as `find-my-way` keeps them encoded during
route lookup.

**Code path(s) it explicitly covers:** `lib/engine.js` → `normalizePathForMatching()`.
This is the **only** function that decodes/normalizes the URL for middleware matching,
called once per request from `run()` (which is invoked from whatever hook middie is
registered on: `onRequest` default, or `preValidation`/`preHandler`/etc.). The fix:

1. Replaces `path = FindMyWay.sanitizeUrlPath(path, useSemicolonDelimiter)` (decodes
   `%2F`→`/`, introducing a phantom segment that breaks `/user/:id/comments`) with
   `path = safeDecodeURI(path, useSemicolonDelimiter).path` (find-my-way's own
   decoder, which preserves `%2F`/`%2f` and other reserved chars while still decoding
   ordinary percent-encoded bytes so decoded middleware prefixes keep matching).
2. Adds `path = decodeNestedPercentEncodedBytes(path)` —
   `path.replace(/%25([0-9A-Fa-f]{2})/g, '%$1')` — to keep nested-percent behavior
   compatible (e.g. `/%2565ncoded`→`/%65ncoded`). This function only ever emits an
   encoded `%XX` token; it **cannot** emit a literal `/`, so it cannot re-introduce a
   segment-breaking slash.
3. (Companion commit `01acaed`, same release) wraps the decoder in `try/catch` and
   returns `{ path, error: FST_ERR_MIDDIE_MALFORMED_URL() }` for malformed percent
   encoding (`/%zz`, `/%`), so the request is rejected with 400 instead of continuing
   with an invalid path; `run()` forwards that error to `holder.done(error)`.

**What the fix does NOT cover / gaps:** None found for this bypass class. Every
materially distinct entry/data path tested is covered because all of them funnel
through the single `normalizePathForMatching` sink, which is now aligned with the
router. The only behavioral discrepancies the fix *introduces* (middie sees `%2F`
literal after `decodeNestedPercentEncodedBytes` of `%252F`, while the router sees
`%252F` literal) are non-segment-breaking on both sides, so the parameterized guard
regexp still matches on the middie side (the condition that determines whether the
guard runs).

## Variant / Alternate Trigger

A bounded, exhaustive set of candidate variants was enumerated and tested on **both**
the vulnerable (9.3.2) and fixed (9.3.3 = latest published) builds. For each
candidate, a Fastify app registered an API-key guard on the parameterized path
`/user/:id/comments` and a handler on the same pattern; the candidate URL was injected
with and without the key. A bypass = `noKey===200 && withKey===200` (guard skipped AND
route reachable).

**Entry point(s) exercised (all `library_api` via `app.inject`, the canonical
Fastify entrypoint):**

- **Encoding variants** against guard `/user/:id/comments`:
  `%2F`; lowercase `%2f`; mixed case `%2F..%2f`; multiple `%2F` in one param
  (`a%2Fb%2Fc`); param = only `%2F` (`/user/%2F/comments`); **double-encoded**
  `%252F`; double lowercase `%252f`; **triple** `%25252F`; two double `%252F..%252F`;
  **quad** `%2525252F`; bare `%25`; `%252` (one hex digit); with query `?x=1`;
  trailing slash `/`; semicolon `;x`; duplicate leading slash `//`.
- **Structural variants**: multi-param guards `/api/:org/:repo` with `%2F` in the
  first and in the second param; prefix guard `/files/:dir` (end:false) + route
  `/files/:dir/download`; double-encoded `%252F` in a multi-param guard.
- **Router-option combinations**: `ignoreTrailingSlash`, `ignoreDuplicateSlashes`,
  `useSemicolonDelimiter`, each alone and all combined, against the slash variants.
- **Method-agnosticism**: GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS.
- **Alternate registration entry point**: guard registered **inside an encapsulated
  prefixed plugin** (`fastify.register(plugin, { prefix: '/api' })` →
  `instance.use('/user/:id/comments', guard)`), exercising the `index.js` prefix
  prepend path (`this.prefix + path`).

**Code path(s) involved:** `lib/engine.js` `normalizePathForMatching()` (the sole
decode sink), `run()`/`Holder.done()` (guard regexp match on `normalizedUrl`), and
`index.js` `use()`/`resolveNormalizationOptions()` (prefix + router-option
resolution). find-my-way `lib/url-sanitizer.js` `safeDecodeURI()` is the decoder both
layers now share.

**Result — no variant/bypass on the fixed build.** Consolidated counts across ~60
probes: **vulnerable 9.3.2 → 24 bypasses; fixed 9.3.3 → 0 bypasses.** The 24
vulnerable hits are exactly the *single-encoded* `%2F` shapes (incl. lowercase,
multiple, query, trailing-slash under `ignoreTrailingSlash`, dup-slash under
`ignoreDuplicateSlashes`, semicolon, all methods, encapsulated prefix). Notably,
**double/triple/quad-encoded `%252F…` bypass NEITHER build** — the vulnerable
`sanitizeUrlPath` is also single-pass, so `%252F` decodes once to literal `%2F` (not
`/`), and the guard matches on both. So nested encoding was never a bypass vector; the
fix's `decodeNestedPercentEncodedBytes` keeps that consistent rather than closing a
live hole.

## Impact

- **Package / component affected:** `@fastify/middie` (`lib/engine.js`,
  `normalizePathForMatching`). Single decode sink; fix replaced it wholesale.
- **Affected versions (as tested):** vulnerable `9.3.2`
  (commit `792d2f46ae68516d3122c9a4468a5748a34efb47`); fixed `9.3.3`
  (commit `e038188b33b9436e1be9f9d1c1920416ec6c18f1`, includes fix `61d90cd` +
  malformed-URL hardening `01acaed`). `9.3.3` is the **latest published** version
  (`npm view @fastify/middie version` → `9.3.3`), so the fixed build is also the
  latest.
- **Risk level / consequences (vulnerable):** Critical — authn/authz (or rate-limit /
  audit) bypass on any parameterized middleware guard with a static suffix after the
  param (`/user/:id/comments`, `/api/:org/:repo/issues`, etc.). Method-agnostic, no
  auth required. **Risk level (fixed):** None for this class — no bypass observed.

## Impact Parity

- **Disclosed / claimed maximum impact (parent):** unauthenticated reach of a
  protected handler on a parameterized middleware path via one crafted URL; HTTP-method
  agnostic; no preconditions.
- **Reproduced impact from this variant run:** on the **vulnerable** build, the parent
  bypass reproduces fully (24 shapes return `200 {"ok":true,"id":"a/b"}` without the
  key, including all HTTP methods and the encapsulated-prefix entry point). On the
  **fixed** build, every candidate returns `401 {"error":"Unauthorized"}` without the
  key while the route remains reachable with the key (`200`) — i.e. the guard
  correctly blocks and the bypass does **not** reproduce.
- **Parity:** `none` for a *new* variant (no additional impact beyond the parent was
  demonstrated, because no fixed-build bypass exists). The parent bypass itself is
  `full` parity on the vulnerable build.
- **Not demonstrated:** no bypass on the patched/latest build; therefore no
  escalation, no RCE, no crash — none claimed and none reproduced.

## Root Cause

The parent root cause: middie's `normalizePathForMatching` decoded `%2F`→`/` (via
`FindMyWay.sanitizeUrlPath`) **before** matching middleware regexps, while
`find-my-way`'s router preserved `%2F` during lookup. For a guard like
`/user/:id/comments` (param + static suffix, compiled with `path-to-regexp`
`end:false`), the decoded `/user/a/b/comments` has an extra segment and fails to match
the guard regexp (`:id`=`a`, then `/b/comments` ≠ `/comments`), so the guard is
skipped; the router still matches `/user/:id/comments` with `id="a/b"` and dispatches
the handler. The fix makes the two layers share the same decoder (`safeDecodeURI`), so
`%2F` stays encoded on both sides and the guard regexp matches.

**Why no variant reaches the same bug on the fixed build:** (1) There is exactly one
path-decode sink (`normalizePathForMatching`); the fix replaced it, so every entry
point (`onRequest`/`preValidation`/`preHandler` hooks, top-level `use`, encapsulated
prefixed `use`, all methods) funnels through the fixed decoder. (2) The only other
transform the fix adds, `decodeNestedPercentEncodedBytes`, maps `%25XX`→`%XX` and can
never produce a literal `/`, so it cannot recreate the segment-count mismatch that the
bypass requires. (3) Among reserved characters, only `/` is segment-breaking for
`path-to-regexp`; the fix preserves `%2F`/`%2f`, and `?`/`#` are stripped pre-decode by
`sanitizeUrl`, while `;` is handled identically by both layers via the shared
`useSemicolonDelimiter` flag. (4) The `req.url` stripping logic in `Holder.done()`
(`origResult` vs normalized fallback) runs *after* the guard-match decision and only
rewrites the remaining URL for downstream middleware; for parameterized guards the
`origResult` (match on the still-encoded sanitized URL) succeeds, so the decoded-path
fallback branch is not taken and cannot be used to slip past the guard.

**Fix commit:** `61d90cd0f578367283b486cb95f3b8c14bf3ddbf`
("fix(engine): preserve encoded slashes in middleware params", released as 9.3.3).
Companion: `01acaed3b2353aef4611cd534b6a7267ca215227` (reject malformed percent
encoding). Advisory: GHSA-2v46-jxjm-7q3v.

## Reproduction Steps

1. The self-contained script is `bundle/vuln_variant/reproduction_steps.sh`. It:
   - Resolves the vulnerable (9.3.2) and fixed (9.3.3) `@fastify/middie` workspaces
     (project cache preferred, `npm install` fallback), ensuring `fastify` is present.
   - Embeds a consolidated Node probe harness and runs it against **both** builds. The
     harness registers a parameterized guard `/user/:id/comments` + handler and
     injects every candidate URL with and without the API key, recording
     `noKeyStatus`, `withKeyStatus`, matched params, and a `bypass` flag.
   - Counts bypasses per build, verifies the control (`%2F` bypasses vulnerable=true,
     fixed=false → harness valid), writes a comparison table and a runtime manifest.
   - **Exit 0** = a bypass was found on the FIXED build (variant confirmed);
     **exit 1** = no bypass on the fixed build (negative result). This run exits 1.
2. Expected evidence: `vulnerable 9.3.2 → 24 bypasses`; `fixed 9.3.3 → 0 bypasses`;
   control `%2F` → vulnerable `200`, fixed `401`. The full per-candidate table is in
   `bundle/logs/vuln_variant/consolidated_comparison.txt`.

## Evidence

- **Master log:** `bundle/logs/vuln_variant/reproduction_steps.log`
- **Consolidated comparison table:** `bundle/logs/vuln_variant/consolidated_comparison.txt`
  (also `bundle/vuln_variant/out/comparison.txt`)
- **Raw probe JSON (both builds):** `bundle/logs/vuln_variant/probe_vuln.json`,
  `bundle/logs/vuln_variant/probe_fixed.json`
- **Runtime manifest:** `bundle/vuln_variant/runtime_manifest.json`
- **Probe harness (embedded copy):** `bundle/vuln_variant/harness_gen/consolidated_probe.js`
  (standalone exploratory harnesses also under `bundle/vuln_variant/harness/`)

Key excerpts (fixed build, no-key requests — all blocked):

```
standard | control_original_%2F      | /user/a%2Fb/comments       | 401/200 | fixedBypass=false
standard | lowercase_%2f             | /user/a%2fb/comments       | 401/200 | fixedBypass=false
standard | double_%252F              | /user/a%252Fb/comments     | 401/200 | fixedBypass=false
standard | triple_%25252F            | /user/a%25252Fb/comments   | 401/200 | fixedBypass=false
standard | two_single_%2F_in_param   | /user/a%2Fb%2Fc/comments   | 401/200 | fixedBypass=false
opts:all  | ignoreTrailing_%2F       | /user/a%2Fb/comments/      | 401/200 | fixedBypass=false
methods   | method_bypass_%2F (POST) | /user/a%2Fb/comments       | 401/200 | fixedBypass=false
prefix    | prefix_%2F               | /api/user/a%2Fb/comments   | 401/200 | fixedBypass=false
SUMMARY: vulnerable bypasses=24  fixed bypasses=0
VERDICT: NO BYPASS on fixed build across all candidate variants.
```

(Vulnerable build, same requests — bypassed: `/user/a%2Fb/comments` no-key →
`200 {"ok":true,"id":"a/b"}` across GET/POST/PUT/PATCH/DELETE/HEAD and the
encapsulated prefix.)

Environment: Node.js v24.18.0; `@fastify/middie` 9.3.2
(`792d2f46…`) and 9.3.3 (`e038188b…`, latest published); `find-my-way` 9.6.0 in both
workspaces; `fastify` from each workspace's `node_modules`.

## Recommendations / Next Steps

The 9.3.3 fix is **complete** for the encoded-slash-in-parameter bypass class; no
code change is required to close a gap (none was found). Recommended **regression
hardening** to lock in the coverage that the existing
`test/security-encoded-slash-param-bypass.test.js` does **not** yet exercise:

- Add param-position cases for **lowercase `%2f`**, **multiple `%2F`** in one param,
  and **param = only `%2F`** (`/user/%2F/comments`).
- Add **double/triple-encoded** `%252F` / `%25252F` param cases (assert the guard
  blocks on the fixed build AND that these were never bypass vectors on the vulnerable
  build, documenting the single-pass decoder behavior).
- Add **method-agnostic** cases: register the route for POST/PUT/DELETE/PATCH and
  assert the guard blocks the `%2F` variant for each method (the CVE states the bypass
  is method-agnostic; the test currently only covers GET).
- Add an **encapsulated-prefix** case (`register(plugin, { prefix })` +
  `instance.use('/user/:id/comments', guard)`) to cover the `index.js` prefix-prepend
  registration path.
- Add `ignoreTrailingSlash` + `ignoreDuplicateSlashes` + `useSemicolonDelimiter`
  param-position combinations (the existing
  `security-router-options-combinations.test.js` covers these only for the static
  `/secret` guard, not for a parameterized guard with a static suffix).

**Defense-in-depth (unchanged from the parent RCA):** do not rely solely on middleware
for authorization; also enforce authorization inside route handlers, and
normalize/reject encoded slashes at the edge where appropriate.

## Additional Notes

- **Idempotency:** `reproduction_steps.sh` was executed multiple times consecutively;
  every run exited 1 with identical results (vulnerable 24 / fixed 0). Output files are
  overwritten each run; no repo checkout state is mutated (read-only use of the project
  cache workspaces; the script never `git checkout`s).
- **Fixed = latest:** `npm view @fastify/middie version` returns `9.3.3`, so the
  fixed build tested here is also the latest published release; the mandatory
  fixed-version and latest-version checks are satisfied by the same build.
- **Trust boundary / threat model:** there is no `SECURITY.md` in the repo; the
  advisory GHSA-2v46-jxjm-7q3v establishes that authn/authz bypass on parameterized
  middleware paths is in-scope and was patched. All candidate variants cross the same
  trust boundary (untrusted HTTP → Fastify server using a middie guard on a
  parameterized path) as the parent.
- **Scope nuance observed:** the bypass requires the parameterized guard to have a
  **static suffix after the param** (`/user/:id/comments`). A prefix-only guard
  (`/user/:id`, `end:false`) is *not* bypassed even on the vulnerable build, because
  the decoded extra slash still matches the open-ended prefix. This is a scoping
  detail of the same root cause, not a distinct variant.
- **Limitations:** tested via `app.inject` (library_api). The parent repro already
  demonstrated the same bypass over a real `127.0.0.1` TCP socket with a raw node http
  client that preserves `%2F`; the fix is transport-agnostic (it is in the path
  normalization, before any I/O layer), so the negative result transfers to the HTTP
  server surface.
