# REPRO-2026-00207: @fastify/middie encoded slash bypass on parameterized middleware paths ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00207 CVE: CVE-2026-14198 ## Package Name: Unknown Ecosystem: Unknown Affected: Unknown Fixed: Unknown ## Root Cause # 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. ## Reproduction Details Reproduced: 2026-07-02T19:41:22.823Z Duration: 586 seconds Tool calls: 128 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00207 pruva-verify CVE-2026-14198 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00207&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00207/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-14198 - Source: fastify/middie ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 15406 bytes) - bundle/repro/rca_report.md (analysis, 8739 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 19672 bytes) - bundle/vuln_variant/rca_report.md (analysis, 17204 bytes) - bundle/ticket.md (ticket, 908 bytes) - bundle/ticket.json (other, 1322 bytes) - bundle/repro/harness/harness_inject.js (other, 1632 bytes) - bundle/repro/harness/harness_server.js (other, 1083 bytes) - bundle/repro/harness/http_client.js (other, 666 bytes) - bundle/repro/runtime_manifest.json (other, 1298 bytes) - bundle/repro/validation_verdict.json (other, 1065 bytes) - bundle/logs/reproduction_steps.log (log, 3459 bytes) - bundle/logs/inject_vuln.log (log, 335 bytes) - bundle/logs/inject_fixed.log (log, 330 bytes) - bundle/logs/vuln_variant/probe_vuln.json (other, 25766 bytes) - bundle/logs/vuln_variant/probe_fixed.json (other, 25777 bytes) - bundle/logs/vuln_variant/method_vuln.json (other, 2534 bytes) - bundle/logs/vuln_variant/method_fixed.json (other, 2535 bytes) - bundle/logs/vuln_variant/prefix_vuln.json (other, 968 bytes) - bundle/logs/vuln_variant/prefix_fixed.json (other, 965 bytes) - bundle/logs/vuln_variant/method_fixed.log (log, 2536 bytes) - bundle/logs/vuln_variant/method_vuln.log (log, 2535 bytes) - bundle/logs/vuln_variant/prefix_fixed.log (log, 966 bytes) - bundle/logs/vuln_variant/prefix_vuln.log (log, 969 bytes) - bundle/logs/vuln_variant/probe_fixed.log (log, 25778 bytes) - bundle/logs/vuln_variant/probe_vuln.log (log, 25767 bytes) - bundle/logs/vuln_variant/consolidated_comparison.txt (other, 7084 bytes) - bundle/logs/vuln_variant/reproduction_steps.log (log, 3129 bytes) - bundle/vuln_variant/harness/variant_probe.js (other, 7787 bytes) - bundle/vuln_variant/harness/method_probe.js (other, 2453 bytes) - bundle/vuln_variant/harness/prefix_probe.js (other, 2287 bytes) - bundle/vuln_variant/out/probe_vuln.log (log, 25767 bytes) - bundle/vuln_variant/out/probe_vuln.json (other, 33113 bytes) - bundle/vuln_variant/out/probe_fixed.log (log, 25778 bytes) - bundle/vuln_variant/out/probe_fixed.json (other, 33132 bytes) - bundle/vuln_variant/out/method_vuln.log (log, 2535 bytes) - bundle/vuln_variant/out/method_fixed.log (log, 2536 bytes) - bundle/vuln_variant/out/method_vuln.json (other, 2534 bytes) - bundle/vuln_variant/out/method_fixed.json (other, 2535 bytes) - bundle/vuln_variant/out/prefix_vuln.log (log, 969 bytes) - bundle/vuln_variant/out/prefix_vuln.json (other, 968 bytes) - bundle/vuln_variant/out/prefix_fixed.log (log, 966 bytes) - bundle/vuln_variant/out/prefix_fixed.json (other, 965 bytes) - bundle/vuln_variant/out/comparison.txt (other, 7084 bytes) - bundle/vuln_variant/harness_gen/consolidated_probe.js (other, 7441 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 1685 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 9965 bytes) - bundle/vuln_variant/variant_manifest.json (other, 5356 bytes) - bundle/vuln_variant/validation_verdict.json (other, 2932 bytes) - bundle/vuln_variant/source_identity.json (other, 2051 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 5386 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00207 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00207/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00207 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev