The bouncer checks IDs at the front door, but the coat-check lets you in through the side entrance with a wink
@fastify/static decodes percent-encoded path separators (%2F) before it resolves files from disk, while Fastify's router treats those same bytes as literal characters when matching route guards. That mismatch — classic CWE-180 (Incorrect Behavior Order: Validate Before Canonicalize) — means a route-level preHandler or auth middleware guarding /admin/* will not match a request to /admin%2Fsecret.json, but the static plugin will happily serve admin/secret.json from the wwwroot. Affected versions track with the sibling advisory GHSA-x428-ghpx-8j92 pattern: @fastify/static up to and including 9.1.0, fixed in 9.1.1. Only applications that (a) mount @fastify/static and (b) rely on Fastify's own routing layer to enforce authorization on static assets are exposed — apps that use a reverse-proxy ACL or a filesystem-level ACL are not.
The vendor tag of MEDIUM / 5.3 (AV:N/AC:L/PR:N/UI:N/C:L/I:N/A:N) is defensible. The bug leaks confidentiality only — no write, no execution, no crash — and only for files the deployer chose to place inside the static root but wanted gated behind Fastify middleware. That is a real bug, but it's a narrow one; the majority of @fastify/static deployments serve fully public assets (bundled JS, CSS, images), in which case there is nothing to bypass. Vendor severity matches reality.
4 steps from start to impact.
Discover a Fastify app serving gated static content
@fastify/static. They then enumerate paths (via ffuf, feroxbuster, or JS-source-mining with LinkFinder) looking for a route pattern like /admin/*, /internal/*, or /private/* that returns 401/403.- Application uses @fastify/static <=9.1.0
- Application uses a Fastify preHandler / hook / plugin to gate a static subtree
- Most static hosting is fully public — nothing to bypass
- Enterprises typically front Fastify with NGINX/Cloudflare/an API gateway that does its own path normalization and ACL
@fastify/static <9.1.1 in SBOMsCraft the encoded-separator payload
%2F — e.g. /admin%2Fsecret.json instead of /admin/secret.json. Tooling is trivial: curl --path-as-is, Burp Repeater, or a one-line Python request with a raw URL.- HTTP/1.1 or HTTP/2 client that does not pre-normalize the URL
- No upstream proxy that decodes and re-canonicalizes
- Cloudflare, Akamai, and Fastly aggressively normalize
%2Fin the path before forwarding - AWS ALB and modern NGINX (with
merge_slashes/normalize_uri) can be configured to reject or decode encoded slashes
%2F sequences at low confidenceBypass the Fastify route guard
find-my-way router matches the literal string /admin%2Fsecret.json, which does not match the /admin/* route the preHandler is attached to. No auth check fires. The request instead falls through to @fastify/static's wildcard reply handler.- Guard is registered at the Fastify router layer, not at the filesystem layer
- If guard is a
onRequestglobal hook rather than a route-scoped preHandler, it still runs and blocks - If the app uses
@fastify/helmet+ a Content-Security-Policy that pins routes, some payloads break
200 for a request whose path contains %2F — easy hunt queryStatic plugin decodes and serves the file
@fastify/static calls send/decodeURIComponent on the path, resolves admin/secret.json inside the static root, and returns 200 with the file body. The attacker now has whatever was in that file — configs, session dumps, backup JSON, unredacted logs.- Sensitive file lives INSIDE the configured
rootdirectory
- Well-architected apps keep secrets out of any static root
- This is
C:L— one file at a time, no lateral movement primitive
@fastify/static to >= 9.1.1 — The only real fix. Bump the pin in package.json, run npm ci, rebuild the image, redeploy. Because this is a MEDIUM verdict, there is no noisgate mitigation SLA — schedule the upgrade inside the 365-day remediation window, but bring it forward if you know you have gated static content.if ($request_uri ~* "%2[fF]") { return 400; }; on Cloudflare enable the Managed Rule for encoded-slash normalization; on AWS ALB use a WAF rule. Buys you time until the upgrade lands — deploy today if you cannot upgrade this week.root. This removes the vulnerability class entirely, not just this CVE. Do it as part of the next sprint regardless.preHandler guards to global onRequest hooks — Global hooks run before route matching, so they see the raw (still-encoded) path and can normalize it themselves. Cheaper than an architectural refactor for teams that cannot upgrade immediately.- Adding an allowlist regex on the route guard — the guard never fires; you cannot filter what you never see.
- Enabling Fastify's
caseSensitive: false— case has nothing to do with%2Fdecoding. - Client-side SPA route guards — bypassed by a direct HTTP request; irrelevant to this class.
- WAF signatures for directory traversal (
../) — this bug is not path traversal; the payload uses a legal encoded slash, not...
The supporting signals.
| In-the-wild exploitation | None observed. Not in CISA KEV. No named campaigns, no incident-response reports. |
|---|---|
| Proof-of-concept | Trivial one-liner: curl --path-as-is https://target/admin%2Ffile. Public writeups exist for the near-identical GHSA-x428-ghpx-8j92 / CVE-2026-6414 demonstrating the same class. |
| EPSS | Expected <0.5% / <30th percentile — consistent with unauthenticated MEDIUM confidentiality-only npm library bugs. Confirm at FIRST EPSS. |
| KEV status | Not listed. No CISA action required. |
| CVSS vector | AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N — network, no auth, no interaction, confidentiality-low only. No integrity, no availability. That ceiling caps the score at 5.3. |
| Affected versions | @fastify/static up to and including 9.1.0 (mirrors the sibling advisory). Any Fastify 4.x/5.x host is affected if the plugin version is vulnerable. |
| Fixed version | @fastify/static >= 9.1.1. No distro backports — this is an npm-only fix. Rebuild and redeploy your container image. |
| Exposure population | @fastify/static sees ~1M weekly npm downloads. Public-internet Fastify surface per Shodan Server: fastify is low-six-figures. Meaningful exposure (gated static + no upstream normalization) is a small fraction of that. |
| Disclosure | 2026 npm advisory chain following the earlier CVE-2026-6414 pattern. Same reporter class (Fastify security WG). |
| Reporter | Fastify security working group / community researcher (per GHSA convention). |
Sources.
- GHSA-x428-ghpx-8j92 — sibling advisory in @fastify/static
- GitHub Advisory DB — CVE-2026-6414 (same class)
- SentinelOne — CVE-2026-6414 writeup
- Snyk — Directory Traversal in @fastify/static
- GitLab Advisory DB — @fastify/static CVE-2026-6414
- Fastify security advisories index
- CWE-180 — Incorrect Behavior Order: Validate Before Canonicalize
- FIRST EPSS API — CVE-2026-7120
Why this verdict
- Impact ceiling is C:L. No RCE, no write, no crash. Even successful exploitation only reads files the deployer chose to place inside a Fastify-served directory. That is a ceiling the CVSS math cannot pierce.
- Prerequisite audit narrows the population sharply. The chain requires: (1) app uses
@fastify/static, AND (2) app relies on Fastify router-level middleware for authz on that static subtree, AND (3) no upstream proxy normalizes%2F. Every mainstream CDN/WAF (Cloudflare, Akamai, Fastly, AWS ALB w/ normalization) breaks step 2. Realistic exposed population is a fraction of a percent of@fastify/staticinstalls. - Role multiplier: none.
@fastify/staticis an application-tier library, not identity/hypervisor/PKI/backup/kernel-agent. There is no realistic deployment where compromising it yields DA, hypervisor escape, or supply-chain pivot. The deployment-role floor does NOT apply — nothing pushes this above MEDIUM. - No KEV, no observed exploitation, low EPSS. The threat pressure is theoretical. Compare with the parent CVE-2026-6414 which has been public longer and still has no reported ITW use.
Why not higher?
HIGH would require either an integrity/availability impact or a role-multiplier compromise (identity, hypervisor, admin plane). Neither is present: the CVSS impact vector is C:L/I:N/A:N and @fastify/static is not a high-value-role component. Even if every deployment were exploitable tomorrow, the worst outcome is disclosure of a single file per request from a directory the developer already chose to serve.
Why not lower?
LOW/IGNORE would be wrong because the bug is unauthenticated, network-reachable, and trivial to weaponize (curl --path-as-is). Any team that put a config or backup JSON in a gated static tree is one request away from disclosure. That's a real bug that deserves a scheduled patch, not a wontfix.
Crowdsourced verification payload.
Run on an auditor workstation or CI job that can reach the target Fastify host. Invoke as ./check-cve-2026-7120.sh https://target.example.com /admin/known-file.json. No privileges required. Exits 1 (VULNERABLE), 0 (PATCHED), 2 (UNKNOWN).
#!/usr/bin/env bash
# noisgate: CVE-2026-7120 @fastify/static route-guard bypass check
# Usage: ./check-cve-2026-7120.sh <base_url> <gated_path>
# e.g. ./check-cve-2026-7120.sh https://app.example.com /admin/config.json
set -u
BASE="${1:-}"
GATED="${2:-}"
if [[ -z "$BASE" || -z "$GATED" ]]; then
echo "UNKNOWN: usage: $0 <base_url> <gated_path>" >&2
exit 2
fi
# Split gated path into prefix and file
PREFIX="$(dirname "$GATED")"
FILE="$(basename "$GATED")"
ENCODED_PATH="${PREFIX}%2F${FILE}"
echo "[*] Baseline: GET ${BASE}${GATED}"
BASELINE=$(curl -sk -o /dev/null -w '%{http_code}' "${BASE}${GATED}")
echo " -> HTTP ${BASELINE}"
echo "[*] Attack: GET ${BASE}${ENCODED_PATH}"
ATTACK=$(curl -sk --path-as-is -o /dev/null -w '%{http_code}' "${BASE}${ENCODED_PATH}")
echo " -> HTTP ${ATTACK}"
# Baseline must be a block (401/403/404) AND attack must be a success (200)
if [[ "$BASELINE" =~ ^(401|403|404)$ ]] && [[ "$ATTACK" == "200" ]]; then
echo "VULNERABLE: guard returned ${BASELINE} but encoded-separator returned 200"
exit 1
fi
if [[ "$BASELINE" == "$ATTACK" ]]; then
echo "PATCHED: guard behavior consistent across canonical and encoded paths (${BASELINE})"
exit 0
fi
echo "UNKNOWN: baseline=${BASELINE} attack=${ATTACK} — inspect manually"
exit 2