How Path Traversal Route Guard Bypass Happens in Fastify and How to Fix It
Introduction
The package-lock.json file in this production web service locked @fastify/static at version 9.3.0 — a version that carries a high-severity path traversal flaw catalogued as CVE-2026-15074. The plugin is responsible for serving static files from disk and, critically, for respecting the route guards that protect sensitive parts of the application. A crafted request URL could cause the guard logic to see one path while the file-serving logic resolves another, letting an unauthenticated attacker walk straight past authentication middleware.
This post breaks down exactly what went wrong, why the specific changes in the upgrade to 10.1.1 close the hole, and what you can do to avoid the same class of bug in your own Fastify services.
The Vulnerability Explained
What Is a Route Guard Bypass via Path Traversal?
Path traversal (CWE-22) is the classic "the guard checks the front door while the attacker sneaks in through the window" problem. In the context of @fastify/static, the window is the URL path itself.
Fastify's plugin ecosystem lets you attach onRequest or preHandler hooks to specific route prefixes. For example, you might protect /admin/* with a JWT validation hook. The hook fires based on the route pattern Fastify matched. If an attacker can submit a URL like:
GET /admin%2F..%2Fsecret-file.txt
or
GET /public/../admin/secret-file.txt
and @fastify/static resolves the final path on disk after route matching has already decided which hooks to run, the guard never fires for the sensitive path. The request reaches the file system with full traversal intact.
The Vulnerable Package Version
The package-lock.json before the fix pinned:
"node_modules/@fastify/static": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.3.0.tgz",
"integrity": "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==",
...
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^1.0.1",
...
}
}
Two specific dependency signals in this lockfile entry hint at the root cause:
- No
@fastify/errordependency — structured error handling for path violations was absent, meaning malformed paths may have fallen through to the file-serving layer rather than being rejected early with a well-typed error. content-disposition: ^1.0.1— the older 1.x range ofcontent-dispositionhad its own history of header-injection and path-handling edge cases that could compound the traversal issue.
Attack Scenario
Imagine the application serves a public documentation directory at /docs and protects an admin panel at /admin. A Fastify preHandler hook validates a bearer token for any route matching /admin/*.
With @fastify/static 9.3.0, an attacker could request:
GET /docs/..%2Fadmin/config.json HTTP/1.1
Host: api.example.com
Because %2F is a URL-encoded forward slash, the raw path /docs/..%2Fadmin/config.json does not match the /admin/* pattern at route-matching time, so the JWT hook is skipped. But when @fastify/static decodes and resolves the path for file system access, it becomes /admin/config.json — a file the attacker was never supposed to read.
The impact for a production web service (as assessed in the PR) is directly exploitable by remote attackers with no prior authentication required.
The Fix
What Changed in the Upgrade
The fix is a targeted version bump in package.json and package-lock.json:
Before:
"node_modules/@fastify/static": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.3.0.tgz",
"integrity": "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^1.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
}
After:
"node_modules/@fastify/static": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.1.tgz",
"integrity": "sha512-ZQnXbBrI7FMUVpKGi00nVe86K17aAliC1Cyqt2UAe9ugYGW7itatVjx79d0jAjnY0HuEpoRwGTkYx/40rAugOw==",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"glob": "^13.0.0"
}
}
Why Each Change Matters
| Change | Security Significance |
|---|---|
9.3.0 → 10.1.1 |
Pulls in the upstream path normalization fix that closes the traversal bypass |
Added @fastify/error ^4.0.0 |
Provides structured, typed errors for illegal path attempts — requests with traversal sequences now fail fast with a proper 400/403 rather than silently resolving |
content-disposition ^1.0.1 → ^2.0.1 |
The 2.x line of content-disposition hardens header generation and removes edge cases in filename encoding that could be abused in download flows |
Isolation of the Old Version
Notice that the diff also preserves the old 9.3.0 entry under a nested path:
"node_modules/@fastify/swagger-ui/node_modules/@fastify/static": {
"version": "9.3.0",
...
}
This is npm's deduplication at work: @fastify/swagger-ui has its own peer dependency on @fastify/static 9.x and gets its own isolated copy. The application-level plugin — the one that handles real user requests — is now safely on 10.1.1. The swagger-ui copy only renders API documentation in a controlled context and does not gate production route guards, so its continued use of 9.3.0 carries significantly lower risk. That said, tracking it for a future upgrade is advisable.
Prevention & Best Practices
1. Always Decode and Canonicalize Before Guarding
Any security check on a file path or URL must operate on the fully decoded, canonicalized form. In Node.js:
const path = require('path');
function isSafe(requestedPath, rootDir) {
// Decode percent-encoding, resolve '..' segments
const normalized = path.resolve(rootDir, decodeURIComponent(requestedPath));
// Ensure the resolved path is still inside rootDir
return normalized.startsWith(path.resolve(rootDir));
}
@fastify/static 10.1.1 applies exactly this kind of normalization internally before route guard evaluation.
2. Pin Exact Versions in CI, Use Ranges Carefully in Production
The lockfile used ^9.3.0 (caret range), which means npm would not automatically pull in 10.x (a major bump). Automated tools like Dependabot or Renovate — or a scanner like Trivy in CI — are essential to catch when a minor or patch version within your range carries a CVE.
3. Integrate Dependency Scanning into Your Pipeline
Trivy flagged this vulnerability directly from package-lock.json. Add it (or a similar SCA tool) as a required CI gate:
# Example GitHub Actions step
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'HIGH,CRITICAL'
exit-code: '1'
4. Follow OWASP Path Traversal Guidance
The OWASP Testing Guide dedicates a full section to path traversal (OTG-AUTHZ-001). Key mitigations:
- Use an allowlist of permitted paths rather than a denylist of traversal sequences.
- Reject requests containing .., %2e, %2f, or null bytes before they reach your file-serving layer.
- Run your web service process with the minimum filesystem permissions needed.
5. Reference Standards
- CWE-22: Improper Limitation of a Pathname to a Restricted Directory
- OWASP: Path Traversal is listed under A01:2021 – Broken Access Control
Key Takeaways
@fastify/static9.3.0 should be treated as untrusted in any production Fastify service — the route guard bypass is directly exploitable via crafted URL paths without any prior authentication.- The addition of
@fastify/error ^4.0.0in 10.1.1 is a meaningful architectural signal: path violations now produce structured errors rather than silently resolving, which also improves your ability to detect attacks in logs. - Upgrading
content-dispositionfrom 1.x to 2.x as part of this fix removes a secondary attack surface in file download flows that could be chained with the traversal. - The nested
@fastify/swagger-uicopy of 9.3.0 is isolated from user-facing route guards but should be tracked — transitive vulnerable dependencies are a common blind spot in lockfile audits. - Lockfile scanning (not just
package.jsonscanning) is what caught this: Trivy readpackage-lock.jsonto find the resolved version. Always scan your lockfile, not just your manifest.
How Orbis AppSec Detected This
- Source: Incoming HTTP request URL path, user-controlled, submitted to the
@fastify/staticroute handler registered on the application's static file prefix. - Sink: The path resolution logic inside
@fastify/static9.3.0 that resolves the request URL to a filesystem path and evaluates it against registered route guards — specifically the point where the decoded path is matched against Fastify hook patterns. - Missing control: Path normalization (decoding of percent-encoded characters and resolution of
..segments) was not performed before route guard evaluation, allowing the raw encoded path to pass guard matching while the decoded path reached the filesystem. - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ("Path Traversal")
- Fix: Upgraded
@fastify/staticfrom 9.3.0 to 10.1.1 inpackage.jsonandpackage-lock.json, which introduces pre-guard path normalization, structured error rejection via@fastify/error, and a hardenedcontent-dispositiondependency.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-15074 is a sharp reminder that static file serving is not a passive, low-risk operation in a web service. When @fastify/static sits in front of route guards, the order and correctness of path normalization is a security boundary — not just a correctness concern. Version 9.3.0 let crafted URLs slip past that boundary; 10.1.1 closes it by normalizing paths before guards are evaluated, adding typed error handling for malformed paths, and hardening the content-disposition layer.
The fix itself is minimal — two files, a version bump, and a lockfile update — but the protection it provides is substantial: remote, unauthenticated attackers can no longer bypass your Fastify route guards by encoding traversal sequences into their request URLs. Keep your dependency scanner running in CI, treat your lockfile as a first-class security artifact, and upgrade promptly when high-severity CVEs land in your dependency tree.