CVE-2026-14198: Verified Reproduction
CVE-2026-14198: @fastify/middie encoded slash bypass on parameterized middleware paths
CVE-2026-14198 is verified against the affected target. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00207.
What Is CVE-2026-14198?
CVE-2026-14198 is a critical authentication/authorization bypass (CWE-436) in @fastify/middie, caused by the middleware layer and Fastify's router disagreeing about a request's canonical path. Pruva reproduced it (reproduction REPRO-2026-00207).
CVE-2026-14198 Severity & CVSS Score
CVE-2026-14198 is rated critical severity, with a CVSS base score of 9.1 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
How to Reproduce CVE-2026-14198
pruva-verify REPRO-2026-00207 curl -O https://www.pruva.dev/api/v1/reproductions/REPRO-2026-00207/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-14198
- reached the target end-to-end
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
URL path with encoded slash %2F in the parameter position, e.g. GET /user/a%2Fb/comments sent without the x-api-key header
- Middie normalizePathForMatching (lib/engine.js) decodes %2F to '/' via FindMyWay.sanitizeUrlPath before matching the parameterized guard /user/:id/comments, so the guard regexp fails to match while Fastify's router (find-my-way) preserves %2F and still dispatches the /user/:id/comments handler; the unauthenticated request reaches the protected handler (HTTP 200).
How the agent worked
Root Cause and Exploit Chain for CVE-2026-14198
@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.
- 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/commentsreturns 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 Fastifyapp.injectlibrary entrypoint and a real127.0.0.1HTTP 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:
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:
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
- The self-contained script is
bundle/repro/reproduction_steps.sh. It:- Reads
bundle/project_cache_context.jsonand reuses the prepared project cache (repo-vuln-v932= 9.3.2 vulnerable,repo= 9.3.3 fixed), with annpm installfallback to@fastify/middie@9.3.2/@fastify/middie@9.3.3if 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.injectand a real HTTP server on127.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.
- Reads
- Expected evidence: the vulnerable build returns
200 {"ok":true,"id":"a/b"}for the unauthenticated/user/a%2Fb/commentsrequest, while the fixed build returns401 {"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(andbundle/logs/inject_vuln.log,bundle/logs/inject_fixed.log) - Real HTTP server evidence:
bundle/artifacts/http/vuln/server.log,bundle/artifacts/http/vuln/responses.txtbundle/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.3or 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 (%2flower-case, double-encoded%252F) and method-agnostic checks (POST/PUT/DELETE).
Additional Notes
- Idempotency:
reproduction_steps.shwas 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 viatrap/SIGTERM, so repeated runs are clean. - Two surfaces, one bug: the bypass is demonstrated both through the canonical
library entrypoint (
app.inject, classified aslibrary_apito match the submitted claim surface) and over a real127.0.0.1TCP socket with a raw node http client that preserves%2F(curl--path-as-iswas 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%2fis equivalent to%2Ffor the decoder and is bypassed the same way. The malformed-percent (/%zz) 400 handling is preserved by the fix.
CVE-2026-14198 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
which node npm && node --version && npm --version/usr/bin/node /usr/bin/npm v24.18.0 11.16.0
cd /data/pruva/project-cache/b133492c-81d6-4c5b-afbf-03b28ec1f618/repo && git log --oneline -5 && echo "---PACKAGE---" && cat package.json | head -30 && echo "---ENGINE DIFF SIZE---" && wc -l lib/engine.js{"cwd":"/data/pruva/runs/1097abc6-d12b-4882-a698-de4bec5dc5c5","duration_ms":7,"exit_code":0,"stderr":"","stdout":"e038188 Bumped v9.3.3\n61d90cd fix(engine): preserve encoded slashes in middleware params\n01acaed fix(engine): reject malformed percent-encoded paths\n378adeb chore: replace http with https in urls (#266)\n298a633 chore(package.json): fix delvedor's personal url\n---PACKAGE---\n{\n \"name\": \"@fastify/middie\",\n \"version\": \"9.3.3\",\n \"description\": \"Middleware engine for Fastify\",\n \"main\": \"index.js\",\n \"type\": \"commonjs\",\n \"types\": \"types/index.d.ts\",\n \"scripts\": {\n \"lint\": \"eslint\",\n \"lint:fix\": \"eslint --fix\",\n \"test\": \"npm run test:unit && npm run test:typescript\",\n \"test:unit\": \"c8 --100 node --test\",\n \"test:typescript\": \"tstyche\"\n },\n \"keywords\": [\n \"fastify\",\n \"middleware\",\n \"webframework\",\n \"performances\"\n ],\n \"author\": \"Matteo Collina <hello@matteocollina.com>\",\n \"contributors\": [\n {\n \"name\": \"Tomas Della Vedova\",\n \"url\": \"https://delvedor.dev\"\n },\n {\n \"name\": \"Manuel Spigolon\",\n \"email\": \"behem… [truncated]cd /data/pruva/project-cache/b133492c-81d6-4c5b-afbf-03b28ec1f618/repo-vuln-v932 && git log --oneline -5 && echo "---PACKAGE---" && cat package.json | head -30 && echo "---ENGINE---" && wc -l lib/engine.js{"cwd":"/data/pruva/runs/1097abc6-d12b-4882-a698-de4bec5dc5c5","duration_ms":6,"exit_code":0,"stderr":"","stdout":"792d2f4 Bumped v9.3.2\nca42dd7 Merge commit from fork\n29162c8 Merge commit from fork\n33c7553 ci: add lock-threads workflow (#259)\ncaf538b build(deps-dev): bump neostandard from 0.12.2 to 0.13.0 (#258)\n---PACKAGE---\n{\n \"name\": \"@fastify/middie\",\n \"version\": \"9.3.2\",\n \"description\": \"Middleware engine for Fastify\",\n \"main\": \"index.js\",\n \"type\": \"commonjs\",\n \"types\": \"types/index.d.ts\",\n \"scripts\": {\n \"lint\": \"eslint\",\n \"lint:fix\": \"eslint --fix\",\n \"test\": \"npm run test:unit && npm run test:typescript\",\n \"test:unit\": \"c8 --100 node --test\",\n \"test:typescript\": \"tsd\"\n },\n \"keywords\": [\n \"fastify\",\n \"middleware\",\n \"webframework\",\n \"performances\"\n ],\n \"author\": \"Matteo Collina <hello@matteocollina.com>\",\n \"contributors\": [\n {\n \"name\": \"Tomas Della Vedova\",\n \"url\": \"http://delved.org\"\n },\n {\n \"name\": \"Manuel Spigolon\",\n \"email\": \"behemoth89@gmail.com\"\n },\n---ENGINE---\n185 lib/engine.js\n","succ… [truncated]Artifacts and Evidence for CVE-2026-14198
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-14198
FAQ: CVE-2026-14198
How does the encoded-slash middleware bypass work?
How severe is CVE-2026-14198?
How can I reproduce CVE-2026-14198?
References for CVE-2026-14198
Authoritative sources for CVE-2026-14198 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.