{"repro_id":"REPRO-2026-00207","version":8,"title":"@fastify/middie encoded slash bypass on parameterized middleware paths","repro_type":"security","status":"published","severity":"critical","description":"@fastify/middie decodes the encoded slash %2F inside path parameter values before matching middleware paths, while Fastify's underlying router preserves the encoding during route lookup. The two layers disagree on the canonical request path: middleware fails to match a URL that the route handler does match. When middleware is used for authentication, authorization, rate limiting, or auditing on parameterized paths, an attacker can reach the protected handler by sending a single crafted URL with an encoded slash in the parameter position.","root_cause":"# CVE-2026-14198 — Root Cause Analysis\n\n## Summary\n\n`@fastify/middie` versions 9.1.0 through 9.3.2 decode the percent-encoded slash `%2F`\ninside path-parameter values **before** matching middleware paths, while Fastify's\nunderlying router (`find-my-way`) preserves the encoding during route lookup. The two\nlayers therefore disagree on the canonical request path: middie normalizes\n`/user/a%2Fb/comments` to `/user/a/b/comments` (which no longer matches the guard\n`/user/:id/comments`), but the router still matches the route and dispatches to the\nhandler. The result is an HTTP-method-agnostic authentication / authorization bypass:\nan unauthenticated attacker reaches a protected handler on a parameterized path by\nembedding an encoded slash in the parameter position.\n\n## Impact\n\n- **Package / component affected:** `@fastify/middie` (`lib/engine.js`,\n  `normalizePathForMatching`).\n- **Affected versions:** 9.1.0 – 9.3.2 (confirmed against 9.3.2; fixed in 9.3.3).\n- **Risk level:** Critical. Any application that uses middie middleware for\n  authentication, authorization, rate limiting, or auditing on a **parameterized**\n  path (e.g. `/api/:resource`, `/user/:id/comments`) can have that guard silently\n  bypassed with a single crafted URL. No authentication or preconditions are required\n  and the bypass is HTTP-method agnostic.\n\n## Impact Parity\n\n- **Disclosed / claimed maximum impact:** Authentication/authorization bypass on\n  parameterized middleware paths; an attacker reaches a protected handler without\n  credentials.\n- **Reproduced impact from this run:** A real Fastify + middie server with an\n  API-key auth guard on `/user/:id/comments` returns **200 `{\"ok\":true,\"id\":\"a/b\"}`**\n  for an unauthenticated request to `/user/a%2Fb/comments` — the guard is bypassed and\n  the protected handler executes. The same request against the fixed build returns\n  **401 Unauthorized**.\n- **Parity:** `full`. The exact claimed bypass (unauthenticated reach of a protected\n  parameterized-path handler via an encoded slash) was demonstrated through both the\n  Fastify `app.inject` library entrypoint and a real `127.0.0.1` HTTP server.\n- **Not demonstrated:** This is an authorization/authn bypass, not memory corruption\n  or code execution; no crash or RCE is claimed or reproduced.\n\n## Root Cause\n\nIn `lib/engine.js`, every request is normalized for middleware matching via\n`normalizePathForMatching(url, options)`. In the vulnerable version that function calls:\n\n```js\npath = FindMyWay.sanitizeUrlPath(path, options.useSemicolonDelimiter)\n```\n\n`sanitizeUrlPath` **decodes** percent-encoded characters, so `%2F` becomes a literal\n`/`. When a guard is registered on a parameterized prefix such as `/user/:id/comments`\n(compiled with `path-to-regexp`, `end:false`), the decoded path\n`/user/a/b/comments` has an extra segment and **fails to match** the guard's regexp.\nmiddie therefore runs zero middleware and Fastify's router — which keeps `%2F` encoded\nduring lookup — still matches the route `/user/:id/comments` (with `id = \"a/b\"`) and\ndispatches the handler. The guard is skipped.\n\nThe fix (commit `61d90cd`, \"fix(engine): preserve encoded slashes in middleware params\",\nreleased as 9.3.3) replaces the decoder with find-my-way's safe decoder that **preserves\nreserved characters** such as `%2F`:\n\n```js\nconst { safeDecodeURI } = require('find-my-way/lib/url-sanitizer')\n...\npath = safeDecodeURI(path, options.useSemicolonDelimiter).path\npath = decodeNestedPercentEncodedBytes(path)   // %25xx -> %xx only\n```\n\nWith `safeDecodeURI`, `/user/a%2Fb/comments` stays `/user/a%2Fb/comments` for\nmiddleware matching, which matches `/user/:id/comments`, so the guard runs and blocks\nunauthenticated requests (401). Ordinary percent-encoded bytes and nested\n`%25xx` encodings remain compatible with previous matching behavior, and the malformed\npercent-encoding 400 handling is preserved.\n\n- **Fix commit:** `61d90cd0f578367283b486cb95f3b8c14bf3ddbf`\n  (\"fix(engine): preserve encoded slashes in middleware params\", v9.3.3).\n- **Advisory ref:** GHSA-2v46-jxjm-7q3v.\n\n## Reproduction Steps\n\n1. The self-contained script is `bundle/repro/reproduction_steps.sh`. It:\n   - Reads `bundle/project_cache_context.json` and reuses the prepared project cache\n     (`repo-vuln-v932` = 9.3.2 vulnerable, `repo` = 9.3.3 fixed), with an `npm install`\n     fallback to `@fastify/middie@9.3.2` / `@fastify/middie@9.3.3` if the cache is absent.\n   - Registers a Fastify app with middie, an API-key auth guard on the parameterized\n     middleware path `/user/:id/comments`, and a protected handler on the same pattern.\n   - Exercises the **library_api** entrypoint via `app.inject` and a **real HTTP\n     server** on `127.0.0.1` (raw node http client that preserves `%2F`) for both the\n     vulnerable and the fixed build.\n   - Asserts: vulnerable bypass → 200 (handler reached, guard bypassed); fixed bypass\n     → 401 (guard matches); baseline → 401; allowed (with key) → 200 for both.\n2. Expected evidence: the vulnerable build returns `200 {\"ok\":true,\"id\":\"a/b\"}` for the\n   unauthenticated `/user/a%2Fb/comments` request, while the fixed build returns\n   `401 {\"error\":\"Unauthorized\"}`. A clean divergence proves the bypass and the patch.\n\n## Evidence\n\n- **Master log:** `bundle/logs/reproduction_steps.log`\n- **Inject harness results:** `bundle/artifacts/inject_vuln.json`,\n  `bundle/artifacts/inject_fixed.json` (and `bundle/logs/inject_vuln.log`,\n  `bundle/logs/inject_fixed.log`)\n- **Real HTTP server evidence:**\n  - `bundle/artifacts/http/vuln/server.log`, `bundle/artifacts/http/vuln/responses.txt`\n  - `bundle/artifacts/http/fixed/server.log`, `bundle/artifacts/http/fixed/responses.txt`\n- **Runtime manifest:** `bundle/repro/runtime_manifest.json`\n\nKey excerpts (real HTTP server, raw node client preserving `%2F`):\n\n```\n=== vulnerable-server /user/a%2Fb/comments (NO api key) ===\nSTATUS:200\n{\"ok\":true,\"id\":\"a/b\"}        <-- guard bypassed, protected handler reached\n\n=== fixed-server /user/a%2Fb/comments (NO api key) ===\nSTATUS:401\n{\"error\":\"Unauthorized\"}      <-- guard now matches and blocks\n\n=== both builds baseline /user/alice/comments (NO api key) ===\nSTATUS:401  {\"error\":\"Unauthorized\"}   <-- guard works for normal paths\n\n=== both builds /user/a%2Fb/comments (WITH api key) ===\nSTATUS:200  {\"ok\":true,\"id\":\"a/b\"}     <-- route still matches when allowed\n```\n\nResult summary from the script:\n\n```\ninject  vuln:  baseline=401 bypass=200 allowed=200\ninject  fixed: baseline=401 bypass=401 allowed=200\nserver  vuln:  baseline=401 bypass=200\nserver  fixed: baseline=401 bypass=401\n```\n\nEnvironment: Node.js v24.18.0, `@fastify/middie` 9.3.2 (vulnerable) and 9.3.3 (fixed),\nFastify from each workspace's `node_modules`. The vulnerable `lib/engine.js` uses\n`FindMyWay.sanitizeUrlPath` (decodes `%2F`); the fixed `lib/engine.js` uses\n`safeDecodeURI` (preserves `%2F`).\n\n## Recommendations / Next Steps\n\n- **Upgrade** to `@fastify/middie@9.3.3` or later immediately. The fix preserves\n  encoded slashes in middleware matching so parameterized guards can no longer be\n  bypassed.\n- **Audit** existing middleware registrations: any guard on a parameterized path\n  (`/:param`, `/api/:resource`, `/user/:id/...`) used for authn/authz/rate-limiting is\n  a candidate bypass surface on vulnerable versions.\n- **Defense in depth:** do not rely solely on middleware for authorization; also\n  enforce authorization inside route handlers, and normalize/reject encoded slashes at\n  the edge where appropriate.\n- **Regression test:** the upstream fix ships\n  `test/security-encoded-slash-param-bypass.test.js`; keep it in CI. Add cases for\n  additional encodings (`%2f` lower-case, double-encoded `%252F`) and method-agnostic\n  checks (POST/PUT/DELETE).\n\n## Additional Notes\n\n- **Idempotency:** `reproduction_steps.sh` was executed twice consecutively; both runs\n  exited 0 with identical results (vulnerable bypass=200, fixed bypass=401). Servers\n  are started on fixed localhost ports and torn down via `trap`/`SIGTERM`, so repeated\n  runs are clean.\n- **Two surfaces, one bug:** the bypass is demonstrated both through the canonical\n  library entrypoint (`app.inject`, classified as `library_api` to match the submitted\n  claim surface) and over a real `127.0.0.1` TCP socket with a raw node http client\n  that preserves `%2F` (curl `--path-as-is` was also verified to preserve `%2F`).\n- **Limitations / edge cases:** the bypass requires the guard to be registered on a\n  *parameterized* path; a static-prefix guard (e.g. `/api`) is not bypassed by this\n  specific vector. Lower-case `%2f` is equivalent to `%2F` for the decoder and is\n  bypassed the same way. The malformed-percent (`/%zz`) 400 handling is preserved by\n  the fix.\n","cve_id":"CVE-2026-14198","cwe_id":"CWE-436","source_url":"fastify/middie","reproduced_at":"2026-07-02T19:41:22.823822+00:00","duration_secs":586.0,"tool_calls":128,"handoffs":2,"total_cost_usd":1.7886675200000004,"agent_costs":{"hypothesis_generator":0.0134076,"judge":0.0109931,"repro":0.4533800999999999,"support":0.0494805,"vuln_variant":1.26140622},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/glm-5p2":0.0134076},"judge":{"gpt-5.4-mini":0.0109931},"repro":{"accounts/fireworks/routers/glm-5p2-fast":0.4533800999999999},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.0494805},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.26140622}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:41:23.665060+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":15406,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":8739,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":19672,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":17204,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":908,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1322,"category":"other"},{"path":"bundle/repro/harness/harness_inject.js","filename":"harness_inject.js","size":1632,"category":"other"},{"path":"bundle/repro/harness/harness_server.js","filename":"harness_server.js","size":1083,"category":"other"},{"path":"bundle/repro/harness/http_client.js","filename":"http_client.js","size":666,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1298,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1065,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":3459,"category":"log"},{"path":"bundle/logs/inject_vuln.log","filename":"inject_vuln.log","size":335,"category":"log"},{"path":"bundle/logs/inject_fixed.log","filename":"inject_fixed.log","size":330,"category":"log"},{"path":"bundle/logs/vuln_variant/probe_vuln.json","filename":"probe_vuln.json","size":25766,"category":"other"},{"path":"bundle/logs/vuln_variant/probe_fixed.json","filename":"probe_fixed.json","size":25777,"category":"other"},{"path":"bundle/logs/vuln_variant/method_vuln.json","filename":"method_vuln.json","size":2534,"category":"other"},{"path":"bundle/logs/vuln_variant/method_fixed.json","filename":"method_fixed.json","size":2535,"category":"other"},{"path":"bundle/logs/vuln_variant/prefix_vuln.json","filename":"prefix_vuln.json","size":968,"category":"other"},{"path":"bundle/logs/vuln_variant/prefix_fixed.json","filename":"prefix_fixed.json","size":965,"category":"other"},{"path":"bundle/logs/vuln_variant/method_fixed.log","filename":"method_fixed.log","size":2536,"category":"log"},{"path":"bundle/logs/vuln_variant/method_vuln.log","filename":"method_vuln.log","size":2535,"category":"log"},{"path":"bundle/logs/vuln_variant/prefix_fixed.log","filename":"prefix_fixed.log","size":966,"category":"log"},{"path":"bundle/logs/vuln_variant/prefix_vuln.log","filename":"prefix_vuln.log","size":969,"category":"log"},{"path":"bundle/logs/vuln_variant/probe_fixed.log","filename":"probe_fixed.log","size":25778,"category":"log"},{"path":"bundle/logs/vuln_variant/probe_vuln.log","filename":"probe_vuln.log","size":25767,"category":"log"},{"path":"bundle/logs/vuln_variant/consolidated_comparison.txt","filename":"consolidated_comparison.txt","size":7084,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":3129,"category":"log"},{"path":"bundle/vuln_variant/harness/variant_probe.js","filename":"variant_probe.js","size":7787,"category":"other"},{"path":"bundle/vuln_variant/harness/method_probe.js","filename":"method_probe.js","size":2453,"category":"other"},{"path":"bundle/vuln_variant/harness/prefix_probe.js","filename":"prefix_probe.js","size":2287,"category":"other"},{"path":"bundle/vuln_variant/out/probe_vuln.log","filename":"probe_vuln.log","size":25767,"category":"log"},{"path":"bundle/vuln_variant/out/probe_vuln.json","filename":"probe_vuln.json","size":33113,"category":"other"},{"path":"bundle/vuln_variant/out/probe_fixed.log","filename":"probe_fixed.log","size":25778,"category":"log"},{"path":"bundle/vuln_variant/out/probe_fixed.json","filename":"probe_fixed.json","size":33132,"category":"other"},{"path":"bundle/vuln_variant/out/method_vuln.log","filename":"method_vuln.log","size":2535,"category":"log"},{"path":"bundle/vuln_variant/out/method_fixed.log","filename":"method_fixed.log","size":2536,"category":"log"},{"path":"bundle/vuln_variant/out/method_vuln.json","filename":"method_vuln.json","size":2534,"category":"other"},{"path":"bundle/vuln_variant/out/method_fixed.json","filename":"method_fixed.json","size":2535,"category":"other"},{"path":"bundle/vuln_variant/out/prefix_vuln.log","filename":"prefix_vuln.log","size":969,"category":"log"},{"path":"bundle/vuln_variant/out/prefix_vuln.json","filename":"prefix_vuln.json","size":968,"category":"other"},{"path":"bundle/vuln_variant/out/prefix_fixed.log","filename":"prefix_fixed.log","size":966,"category":"log"},{"path":"bundle/vuln_variant/out/prefix_fixed.json","filename":"prefix_fixed.json","size":965,"category":"other"},{"path":"bundle/vuln_variant/out/comparison.txt","filename":"comparison.txt","size":7084,"category":"other"},{"path":"bundle/vuln_variant/harness_gen/consolidated_probe.js","filename":"consolidated_probe.js","size":7441,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1685,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":9965,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":5356,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2932,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2051,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":5386,"category":"other"}]}