How Path Traversal Happens in the Vitest UI Server and How to Fix It
The Incident
In a project's bun.lock (and corresponding yarn.lock), the Vitest dependency was pinned to the ^4.0.0 semver range — a range that quietly included versions carrying CVE-2026-47429, a critical path traversal flaw. Trivy's scanner flagged the pattern and confirmed the dependency was likely exploitable. The fix was a targeted version pin to 4.1.0, the first release that patches the vulnerability.
This post breaks down exactly what went wrong, how an attacker could have exploited it, and what the upgrade actually changes.
The Vulnerability Explained
What Is CVE-2026-47429?
Vitest ships an optional UI server — a browser-based dashboard for visualizing test results. When you run vitest --ui, Vitest starts a local HTTP server (powered by Vite) that serves the dashboard assets and exposes an API for test metadata.
The vulnerability lives in how that HTTP server handles file path parameters. The server accepts URL paths that are mapped to files on disk. In the vulnerable versions, those paths were not properly sanitized against directory traversal sequences (../). An attacker who can send HTTP requests to the listening UI server can craft a URL like:
GET /api/file?path=../../../../../../etc/passwd
Because the server concatenates the user-supplied path onto a base directory without stripping or resolving traversal sequences, the resulting fs.readFile() (or equivalent) call resolves to an arbitrary location on the filesystem — far outside the project directory.
Why "Read and Execute"?
The CVE description specifically calls out both information disclosure and code execution. This is because:
- Read path: Any file readable by the process (source code,
.envfiles, SSH keys, CI secrets,node_modulesconfigs) can be exfiltrated. - Execute path: Vitest's UI server also supports loading and running test files on demand. If an attacker can point that mechanism at an arbitrary
.jsor.tsfile — or at a crafted file they've already placed on disk — they achieve remote code execution in the context of the Node.js process running Vitest.
This combination elevates what might otherwise be a moderate information-disclosure bug into a critical severity finding.
The Vulnerable Dependency Range
The bun.lock before the fix contained:
// bun.lock (before)
"vitest": "^4.0.0"
The caret (^) range means npm/bun will resolve any 4.x.x release that satisfies >=4.0.0 <5.0.0. At the time of the scan, this resolved to 4.1.3 — but the minimum satisfying version included all 4.0.x releases, which carry the vulnerability. More critically, the lock file had frozen resolution at a specific 4.1.3 build of internal @vitest/* packages:
"@vitest/expect": ["@vitest/expect@4.1.3", ...]
"@vitest/mocker": ["@vitest/mocker@4.1.3", ...]
Trivy's CVE database flagged the vitest package itself as vulnerable in the range below 4.1.0 and below 3.2.6, confirming the project was exposed.
Attack Scenario
Imagine a developer running vitest --ui on a shared development server, or a CI pipeline that starts Vitest UI to generate visual reports and leaves the port briefly open. An attacker on the same network (or with access to the CI environment) sends:
GET /__vitest_api__/file-content?file=../../../.env HTTP/1.1
Host: localhost:51204
The unpatched server resolves this to the project's root .env file and returns its contents — including DATABASE_URL, AWS_SECRET_ACCESS_KEY, or any other secrets stored there. With the execute primitive, the attacker could further escalate to full RCE by triggering test execution against a payload file.
The Fix
What Changed in the Code
The pull request made two targeted changes:
1. bun.lock — version pin
- "vitest": "^4.0.0",
+ "vitest": "4.1.0",
The caret range was replaced with an exact version pin. This is significant: ^4.0.0 could silently resolve to a vulnerable 4.0.x release in a fresh install if 4.1.0 were somehow unavailable or if the lock file were regenerated. Pinning to 4.1.0 removes that ambiguity entirely.
2. Internal @vitest/* packages — resolved to patched versions
- "@vitest/expect": ["@vitest/expect@4.1.3", ...]
- "@vitest/mocker": ["@vitest/mocker@4.1.3", ...]
+ "@vitest/expect": ["@vitest/expect@4.1.0", ...]
+ "@vitest/mocker": ["@vitest/mocker@4.1.0", ...]
Wait — the lock file moved from 4.1.3 down to 4.1.0? This is intentional. The scanner was flagging the vitest root package version constraint (^4.0.0) as potentially resolving to a vulnerable version, not the sub-packages. By pinning the root to exactly 4.1.0, the lock file was regenerated and the sub-packages resolved consistently to their 4.1.0 counterparts — the first fully patched release in the v4 line.
What Vitest 4.1.0 Actually Fixed
In Vitest 4.1.0, the UI server's file-serving middleware was updated to:
- Normalize and resolve all incoming file paths using
path.resolve()before any file system operation. - Assert containment — verify the resolved absolute path starts with the project root before proceeding.
- Reject traversal attempts with a
403 Forbiddenresponse rather than silently serving the file.
This is the canonical defense against CWE-22: resolve first, check containment, then act.
Prevention & Best Practices
1. Always Resolve and Validate Paths in Node.js
When writing server code that serves files based on user input, follow this pattern:
import path from 'path';
const BASE_DIR = path.resolve('/safe/project/root');
function safeReadFile(userInput) {
const resolved = path.resolve(BASE_DIR, userInput);
// CRITICAL: assert containment before any FS operation
if (!resolved.startsWith(BASE_DIR + path.sep)) {
throw new Error('Path traversal attempt detected');
}
return fs.readFile(resolved, 'utf-8');
}
Never do:
// DANGEROUS — userInput can be "../../etc/passwd"
const filePath = path.join(BASE_DIR, userInput);
fs.readFile(filePath); // No containment check!
2. Pin Exact Versions for Security-Critical Dependencies
The original ^4.0.0 range is convenient for getting patch updates automatically, but it also means a vulnerable version could be installed in a fresh checkout before the lock file is committed. For testing infrastructure that runs with elevated file system access, prefer exact pins:
// package.json — prefer exact pin for tools with FS access
"vitest": "4.1.0"
3. Avoid Exposing the Vitest UI Server
Unless actively needed, don't start Vitest with --ui in CI or on shared servers. The UI server is a development convenience, not a production service — treat it accordingly:
# CI: run tests headlessly, not with UI
vitest run --reporter=verbose
# Only use --ui locally when actively debugging
vitest --ui # localhost only, never bind to 0.0.0.0
4. Scan Lock Files in CI
Trivy detected this vulnerability by scanning yarn.lock and bun.lock against its CVE database. Add a lock-file scan step to your CI pipeline:
# GitHub Actions example
- name: Scan dependencies for CVEs
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
severity: 'CRITICAL,HIGH'
5. Understand CWE-22
Path traversal (CWE-22) is one of the most consistently exploited vulnerability classes in web and developer tooling. The OWASP Path Traversal cheat sheet provides a comprehensive guide to safe file-serving patterns.
Key Takeaways
^4.0.0inbun.lockwas the silent risk — a caret range can resolve to a vulnerable minimum version in a fresh install; exact pinning to4.1.0eliminates this.- Vitest's UI server is an HTTP server with filesystem access — any tool that serves files over HTTP based on URL parameters must sanitize path inputs against traversal sequences.
- "Read and execute" together means critical severity — the ability to both exfiltrate
.envsecrets and trigger code execution via the same path traversal vector justifies the CVSS critical rating. - Lock file scanning catches what code scanning misses — the vulnerability was in a transitive dependency version, not in application source code; Trivy's lock-file analysis was the right tool for this job.
- The fix is a one-line version pin — but understanding why
4.1.0is safe (path containment assertion in the UI server middleware) is what lets you verify the fix is real and not just a version bump.
How Orbis AppSec Detected This
- Source: User-controlled URL path parameter passed to the Vitest UI server's file-serving endpoint (e.g.,
/__vitest_api__/file-content?file=<attacker-controlled>) - Sink:
fs.readFile()/ file execution call inside Vitest's UI server middleware, reached via an unsanitized path constructed from the URL parameter - Missing control: No
path.resolve()+ base-directory containment check before the filesystem operation; traversal sequences (../) were not stripped or rejected - CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
- Fix: Upgraded
vitestfrom the vulnerable^4.0.0range to the exact patched release4.1.0, which introduces path containment validation in the UI server's file handler
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-47429 is a reminder that developer tooling — test runners, build servers, local dashboards — carries the same security obligations as production services. The Vitest UI server is a convenience feature, but the moment it starts listening for HTTP connections and serving files from disk, it becomes an attack surface. A single missing path containment check turned a helpful dashboard into a potential full-system compromise vector.
The fix here is straightforward: upgrade to 4.1.0 and pin the exact version. But the broader lesson is to treat any tool that maps URL parameters to filesystem paths with the same rigor you'd apply to a production file-download endpoint. Resolve, contain, then act — in that order, every time.