diff --git a/src/server.tsx b/src/server.tsx index fd6b0916..eb1f3dd6 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -3,6 +3,8 @@ import type { ServerWebSocket } from 'bun'; import { serve } from 'bun'; import { honoServer } from './servers/hono'; import { verify } from './servers/jwt'; +import { isSuperAdmin } from './servers/super-admin'; +import { isWsProviderAllowedForNonOwner } from './servers/_middlewares'; import { isTokenBlacklisted } from 'officerdb'; import { terminalWebsocket } from './servers/api/terminal/websocket'; import { chatWebsocket } from './servers/api/chat/websocket'; @@ -153,6 +155,14 @@ async function upgradeWs( if (await isTokenBlacklisted(user.jti)) return new Response('Unauthorized', { status: 401 }); } + // The account backstop, applied to sockets. Everything above this line AUTHENTICATES — it proves who + // is calling and never asks what they may reach. That is why a Member with a valid token could open + // a terminal here in the same minute it was 403'd on GET /api/tasks. Same rule as + // originScopeMiddleware, deliberately declared in that same file so the two cannot drift apart. + if (!(await isSuperAdmin(user)) && !isWsProviderAllowedForNonOwner(provider)) { + return new Response('Forbidden', { status: 403 }); + } + const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; const cwd = url.searchParams.get('cwd') ?? undefined; diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index b7ccf67e..4e1ecc97 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -57,6 +57,29 @@ const APP_ORIGINS: string[] = APP_ORIGIN_LIST.map((a) => a.origin); // this is the ACCOUNT backstop below, not an origin rule — it holds whatever Origin a caller claims. const NON_OWNER_PATHS = ['/api/auth', '/api/music']; +// The SAME rule, for the other door into the platform. +// +// WebSocket upgrades never reach this file's middleware. Bun's route table in server.tsx matches +// '/api/terminal/ws' and friends before the '/api/*' catch-all that hands off to Hono, so the account +// backstop below — and every other Hono middleware — is simply not on that code path. It was written +// when routes were the only surface anyone was thinking about. +// +// The consequence was demonstrated on 2026-08-06: a Member token 403'd on `GET /api/tasks` opened +// `/api/tasks/pipeline/ws` with a 101 in the same minute. Terminal, chat, task-runner, pipeline and +// desktop were all reachable — a shell, the agent with --dangerously-skip-permissions, arbitrary script +// execution, and the owner's physical screen. +// +// Kept here, beside NON_OWNER_PATHS, because these two are one rule expressed at two doors. Split them +// across files and they drift; the drift is invisible until someone tries it. `cliamp`/`cliamp-audio` +// are the socket half of `/api/music` — the music app's playback transport, which is exactly what a +// music account is for. +const NON_OWNER_WS_PROVIDERS = ['cliamp', 'cliamp-audio']; + +/** Whether a non-owner account may open this websocket provider. Owners bypass this entirely. */ +export function isWsProviderAllowedForNonOwner(provider: string): boolean { + return NON_OWNER_WS_PROVIDERS.includes(provider); +} + function pathAllowed(path: string, prefixes: string[]): boolean { return prefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); }