Skip to content

CVE-2026-14198: Verified Repro With Script Download

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.

REPRO-2026-00207 Variant found Jul 2, 2026 CVE entry ↗ .txt
Severity
CRITICAL
CVSS
9.1
Confidence
HIGH
Reproduced in
9m 46s
Tool calls
128
Spend
$1.79
01 · Overview

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).

02 · Severity & CVSS

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 threat level
9.1 / 10 CVSS base
Weakness CWE-436

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
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00207/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-14198

Authorization bypass — reproduced
  • reached the target end-to-end
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
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

Attack chain
  1. 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).
Variants tested

Negative variant result. Bounded, exhaustive search for a distinct variant or bypass of the @fastify/middie 9.3.3 fix for CVE-2026-14198 (encoded-slash authz bypass on parameterized middleware paths). Tested ~60 materially distinct candidate probes across encodings (%2F/%2f/mixed/multiple/%2F-only, double %252F, tripl…

How the agent worked 280 events · 128 tool calls · 10 min
10 minDuration
128Tool calls
67Reasoning steps
280Events
Agent activity over 10 min
Support
16
Hypothesis
2
Repro
81
Judge
27
Variant
150
0:0009:46

Root Cause and Exploit Chain for CVE-2026-14198

Versions: 9.1.0 – 9.3.2 (confirmed against 9.3.2; fixed in 9.3.3).

@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/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:

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

  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.

Variant Analysis & Alternative Triggers for CVE-2026-14198

Versions: versions (as tested): vulnerable 9.3.2

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.jsnormalizePathForMatching(). 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.

  • 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 version9.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 checkouts).
  • 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.

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.

Event 1/40
0:000:49
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · CVE-2026-14198 · REPRO-20
0:02
0:03
web search
0:05
web search
0:07
0:08
0:10
0:11
web search
0:18
0:18
extract_facts
no facts extracted
0:20
0:20
0:20
supportrepro
0:34
0:35
0:35
0:35
0:37
0:37
0:43
0:43
0:43
$which node npm && node --version && npm --version
/usr/bin/node
/usr/bin/npm
v24.18.0
11.16.0
0:44
0:44
0:44
0:46
0:46
$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]
0:46
$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]
0:49

Artifacts and Evidence for CVE-2026-14198

Scripts, logs, diffs, and output captured during the reproduction.

bundle/ticket.md0.9 KB
bundle/ticket.json1.3 KB
bundle/repro/harness/harness_inject.js1.6 KB
bundle/repro/harness/harness_server.js1.1 KB
bundle/repro/harness/http_client.js0.7 KB
bundle/repro/runtime_manifest.json1.3 KB
bundle/repro/validation_verdict.json1.0 KB
bundle/logs/reproduction_steps.log3.4 KB
bundle/logs/inject_vuln.log0.3 KB
bundle/logs/inject_fixed.log0.3 KB
bundle/logs/vuln_variant/probe_vuln.json25.2 KB
bundle/logs/vuln_variant/probe_fixed.json25.2 KB
bundle/logs/vuln_variant/method_vuln.json2.5 KB
bundle/logs/vuln_variant/method_fixed.json2.5 KB
bundle/logs/vuln_variant/prefix_vuln.json0.9 KB
bundle/logs/vuln_variant/prefix_fixed.json0.9 KB
bundle/logs/vuln_variant/method_fixed.log2.5 KB
bundle/logs/vuln_variant/method_vuln.log2.5 KB
bundle/logs/vuln_variant/prefix_fixed.log0.9 KB
bundle/logs/vuln_variant/prefix_vuln.log0.9 KB
bundle/logs/vuln_variant/probe_fixed.log25.2 KB
bundle/logs/vuln_variant/probe_vuln.log25.2 KB
bundle/logs/vuln_variant/consolidated_comparison.txt6.9 KB
bundle/logs/vuln_variant/reproduction_steps.log3.1 KB
bundle/vuln_variant/harness/variant_probe.js7.6 KB
bundle/vuln_variant/harness/method_probe.js2.4 KB
bundle/vuln_variant/harness/prefix_probe.js2.2 KB
bundle/vuln_variant/out/probe_vuln.log25.2 KB
bundle/vuln_variant/out/probe_vuln.json32.3 KB
bundle/vuln_variant/out/probe_fixed.log25.2 KB
bundle/vuln_variant/out/probe_fixed.json32.4 KB
bundle/vuln_variant/out/method_vuln.log2.5 KB
bundle/vuln_variant/out/method_fixed.log2.5 KB
bundle/vuln_variant/out/method_vuln.json2.5 KB
bundle/vuln_variant/out/method_fixed.json2.5 KB
bundle/vuln_variant/out/prefix_vuln.log0.9 KB
bundle/vuln_variant/out/prefix_vuln.json0.9 KB
bundle/vuln_variant/out/prefix_fixed.log0.9 KB
bundle/vuln_variant/out/prefix_fixed.json0.9 KB
bundle/vuln_variant/out/comparison.txt6.9 KB
bundle/vuln_variant/harness_gen/consolidated_probe.js7.3 KB
bundle/vuln_variant/runtime_manifest.json1.6 KB
bundle/vuln_variant/patch_analysis.md9.7 KB
bundle/vuln_variant/variant_manifest.json5.2 KB
bundle/vuln_variant/validation_verdict.json2.9 KB
bundle/vuln_variant/source_identity.json2.0 KB
bundle/vuln_variant/root_cause_equivalence.json5.3 KB
bundle/repro/reproduction_steps.sh15.0 KB
bundle/repro/rca_report.md8.5 KB
bundle/vuln_variant/reproduction_steps.sh19.2 KB
bundle/vuln_variant/rca_report.md16.8 KB
08 · How to Fix

How to Fix CVE-2026-14198

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-14198 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-14198

How does the encoded-slash middleware bypass work?

An attacker sends a single request with an encoded slash embedded in a path-parameter position, such as /user/a%2Fb/comments where /user/:id/comments is protected by authentication, authorization, rate-limiting, or auditing middleware. Middie's decoded view of the path fails to match the guard and skips it, but Fastify's router still dispatches the undecoded path to the protected handler — the bypass works regardless of HTTP method.

How severe is CVE-2026-14198?

Critical — no authentication or preconditions are required, and any application using middie to guard a parameterized path (e.g. /api/:resource) can have that guard silently bypassed with one crafted URL.

How can I reproduce CVE-2026-14198?

Download the verified script from this page and run it in an isolated environment against a Fastify server using @fastify/middie 9.1.0-9.3.2 with an API-key guard on a parameterized route. It sends a request with an encoded slash in the path parameter and shows the protected handler responding without the middleware guard firing.
11 · References

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.