| Description | pi-agent-dashboard starts a local Fastify server on 127.0.0.1:8000 (default install: Electron desktop app, standalone mode, or pi-dashboard CLI). The same HTTP listener serves the REST API and three WebSocket gateways: /ws (browser gateway), /ws/terminal/<id> (PTY terminals) and /live/<id>.
When no OAuth provider is configured (the default state — /auth/status reports authEnabled:false), the WebSocket upgrade gate authorizes a connection only by checking that the TCP peer address is loopback. There is no Origin check, no CSRF token, and no local-IPC token requirement on this path.
Because the WebSocket handshake is a plain HTTP GET Upgrade — not subject to CORS preflight, and not covered by the browser same-origin policy for subsequent WS traffic — any website opened in the victim's browser can dial ws://127.0.0.1:8000/ws. The browser opens that TCP connection from the victim's own machine, so the server sees a loopback peer and admits the attacker's page.
The affected behavior includes:
Any web Origin can open the browser gateway WebSocket /ws with no authentication.
Every new connection immediately receives the sessions_snapshot broadcast: every session's id, name, cwd and status — disclosure of all working directories and agent session state.
A page can send {"type":"create_terminal","cwd":"C:\\Users\\Public"}; the server spawns a real PTY (cmd.exe via %COMSPEC% on Windows, $SHELL on unix) with no authorization check.
The terminal_added broadcast discloses the new terminal id; the page then connects ws://127.0.0.1:8000/ws/terminal/<id> (same loopback-only gate) and every binary WebSocket frame is written verbatim into the PTY — a fully interactive shell, arbitrary command execution as the desktop user.
Other high-impact browser-gateway messages reachable the same way: send_prompt (inject arbitrary prompts into live agent sessions — agents hold shell tools), spawn_session, kill_process, force_kill.
Additionally, REST mutations that read no JSON body (e.g. POST /api/tunnel-connect, POST /api/tunnel-disconnect, POST /api/pi-core/update) can be triggered cross-site as blind CSRF: CORS gates only the reading of responses, never the delivery of simple requests.
Severity: Critical — CVSS 3.1 AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H (9.6). Attacker requirements are minimal: the victim only needs the dashboard running and to open one attacker-controlled web page (no click required; the page can auto-run on load).
Affected version: v0.8.0 (current release) and earlier, in the default configuration (no OAuth provider configured). Verified on the Electron desktop app (Windows x64) and the npm CLI layout.
2. Vulnerability Principle
The browser same-origin policy does not protect WebSocket endpoints the way it protects REST reads: the WS handshake is an HTTP GET Upgrade that never triggers a CORS preflight, and once the socket is open, message traffic is not filtered by origin at all. A WebSocket server must enforce its own Origin policy — the W3C WebSockets security model explicitly says so ("the server should verify the Origin header").
The dashboard's WS gate instead treats "the TCP peer address is loopback" as proof that the request comes from a trusted local process. A WebSocket dialled by JavaScript running on any website is also opened by the victim's own browser on the victim's own machine — its TCP source address is 127.0.0.1. The loopback check therefore cannot distinguish the dashboard's own web UI from arbitrary web content, and the missing Origin check means nothing else does either.
The issue follows this chain:
The victim has pi-agent-dashboard running on 127.0.0.1:8000 (default, no OAuth configured).
The victim opens any attacker-controlled web page.
The page executes new WebSocket('ws://127.0.0.1:8000/ws'). No preflight happens; the server's upgrade gate sees a loopback peer and accepts.
On connect the server pushes sessions_snapshot — all session ids, names, working directories and statuses are now in attacker hands.
The page sends {"type":"create_terminal","cwd":"C:\\Users\\Public"}. The handler calls terminalManager.spawn(cwd) with no authorization check, starting a PTY shell.
The terminal_added broadcast (fanned out to every connected client, including the attacker's) reveals the terminal id. The page dials ws://127.0.0.1:8000/ws/terminal/<id> — the same loopback-only gate admits it — and every binary frame it sends is piped straight into pty.write(). The attacker has an interactive shell and can read the PTY output stream back over the same socket.
For the REST layer, the CORS policy (isCorsOriginAllowed) correctly refuses to echo Access-Control-Allow-Origin for unknown origins — so cross-origin pages cannot read REST responses. But CORS never gates request delivery: simple requests (GET, or POST with no body / text/plain body) are still sent and executed by the server. Handlers such as /api/tunnel-connect read no request body at all, so a fetch(..., {method:'POST', mode:'no-cors'}) from any website drives them — a blind CSRF primitive that changes server state (tunnel lifecycle, pi-core update) even though the response stays opaque.
3. Vulnerability Root-Cause Code
3.1 The WS upgrade gate authorizes by TCP peer address only — no Origin check on the default path
In packages/server/src/server.ts, the upgrade handler:
2349: fastify.server.on("upgrade", (request, socket, head) => {
2350: // Access check for WebSocket upgrades
2351: const remoteAddress = request.socket.remoteAddress || "";
...
2354: // Ephemeral single-use ticket (D11) bound to the requested WS route
2355: // scope. Origin check is defense-in-depth only (absent-Origin exists),
2356: // never the sole gate.
...
2371: if (config.authConfig?.secret) {
2372: if (!validateWsUpgrade(request.headers.cookie, remoteAddress, config.authConfig.secret, trusted, { ticket, scope, consumeTicket, headers: wsHeaders, localToken })) {
2373: socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
2374: socket.destroy();
2375: return;
2376: }
2377: } else if (
2378: !isGenuinelyLocal(remoteAddress, wsHeaders) &&
2379: !verifyLocalToken(wsHeaders, localToken) &&
2380: (trusted.length === 0 || !isBypassedHost(remoteAddress, trusted)) &&
2381: !(scope && ticket && consumeTicket(ticket, scope))
2382: ) {
2383: // No auth configured — allow genuine-local, local-IPC token, trusted
2384: // networks, or a valid single-use ticket. A tunnel presenting as
2385: // 127.0.0.1 (forwarding header) is NOT trusted (D10, narrowed).
2386: socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
2387: socket.destroy();
2388: return;
2389: }
With no OAuth provider configured (config.authConfig?.secret unset — the default), the else if branch is the only gate. A browser-dialled WebSocket from any website satisfies isGenuinelyLocal (peer 127.0.0.1, no forwarding headers), so the whole condition short-circuits and the upgrade is accepted. The comment at 2355 mentions an Origin check as "defense-in-depth", but no Origin check exists on this path.
3.2 "Genuinely local" means loopback source IP — which browser requests also have
In packages/server/src/auth/localhost-guard.ts:
0043: export function isGenuinelyLocal(ip: string, headers: HeaderBag): boolean {
0044: return isLoopback(ip) && !hasProxyForwardingHeaders(headers);
0045: }
This correctly rejects a tunnel relaying through the host (forwarding-header heuristic), but it cannot distinguish "a local process" from "the user's browser, driven by an arbitrary website". Browser-originated connections to 127.0.0.1:8000 present exactly the same peer address as the dashboard's own UI.
3.3 create_terminal spawns a PTY with no authorization check
In packages/server/src/browser-handlers/terminal-handler.ts:
0007: export function handleCreateTerminal(
0008: msg: Extract<BrowserToServerMessage, { type: "create_terminal" }>,
0009: ctx: BrowserHandlerContext,
0010: ): void {
0011: const { terminalManager, sessionOrderManager, broadcast } = ctx;
0012: if (terminalManager && sessionOrderManager) {
0013: const terminal = terminalManager.spawn(msg.cwd);
0014: sessionOrderManager.insert(msg.cwd, terminal.id);
0015: broadcast({ type: "terminal_added", terminal });
0016: broadcast({ type: "sessions_reordered", cwd: msg.cwd, s |
|---|