diff --git a/backend/src/__tests__/exec.test.ts b/backend/src/__tests__/exec.test.ts index e9e4718b..eb6940a1 100644 --- a/backend/src/__tests__/exec.test.ts +++ b/backend/src/__tests__/exec.test.ts @@ -355,6 +355,89 @@ describe('WebSocket upgrade - exec auth enforcement', () => { expect(code).toBe(403); }); + it('rejects WebSocket upgrade with mfa_pending token (403)', async () => { + // Partial-auth must not open /ws (upgrade early-reject + generic deny-by-default). + // Pre-fix: any set scope skipped the admin check and unlocked execContainer. + const token = jwt.sign( + { scope: 'mfa_pending', user_id: 1, username: 'viewer' }, + TEST_JWT_SECRET, + { expiresIn: '5m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('rejects WebSocket upgrade with pilot_enroll token (403)', async () => { + const token = jwt.sign( + { scope: 'pilot_enroll', nodeId: 1, enrollNonce: 'test-nonce' }, + TEST_JWT_SECRET, + { expiresIn: '15m' }, + ); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('rejects WebSocket upgrade with an unknown scoped JWT (403)', async () => { + const token = jwt.sign({ scope: 'future_machine_scope' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + + it('accepts WebSocket upgrade with pilot_tunnel token (pilot loopback)', async () => { + // Agent loopback injects pilot_tunnel on every forwarded WS, including /ws. + const token = jwt.sign({ scope: 'pilot_tunnel', nodeId: 1 }, TEST_JWT_SECRET, { expiresIn: '1h' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const connected = await new Promise((resolve) => { + ws.on('open', () => { + ws.close(); + resolve(true); + }); + ws.on('error', () => resolve(false)); + ws.on('unexpected-response', () => resolve(false)); + }); + expect(connected).toBe(true); + }); + + it('accepts WebSocket upgrade with container-exec console_session (remote exec)', async () => { + const { mintConsoleSession } = await import('../helpers/consoleSession'); + const token = mintConsoleSession({ path: 'container-exec' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const connected = await new Promise((resolve) => { + ws.on('open', () => { + ws.close(); + resolve(true); + }); + ws.on('error', () => resolve(false)); + ws.on('unexpected-response', () => resolve(false)); + }); + expect(connected).toBe(true); + }); + + it('rejects host-console console_session on /ws (path gate before allowlist)', async () => { + // Path mismatch is enforced in upgradeHandler (consoleSessionPathForPathname) + // before the generic allowlist; allowlist must not be the sole path gate. + const { mintConsoleSession } = await import('../helpers/consoleSession'); + const token = mintConsoleSession({ path: 'host-console' }); + const ws = new WebSocket(getWsUrl(), { headers: { Authorization: `Bearer ${token}` } }); + const code = await new Promise((resolve) => { + ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0)); + ws.on('error', () => resolve(0)); + }); + expect(code).toBe(403); + }); + it('accepts WebSocket upgrade with admin token', async () => { const token = jwt.sign( { username: TEST_USERNAME, role: 'admin' }, diff --git a/backend/src/websocket/generic.ts b/backend/src/websocket/generic.ts index bf93c84b..29c339cd 100644 --- a/backend/src/websocket/generic.ts +++ b/backend/src/websocket/generic.ts @@ -7,6 +7,23 @@ import { NodeRegistry } from '../services/NodeRegistry'; import { isDebugEnabled } from '../utils/debug'; import { rejectUpgrade as reject } from './reject'; +/** + * Scoped JWTs allowed on the generic `/ws` upgrade after the upgrade handler's + * earlier gates. Deny-by-default: mfa_pending, pilot_enroll, node_proxy (also + * rejected via isProxyToken), and any unknown future scope must not skip the + * session admin check. + * + * - api_token: restricted scopes blocked upstream for /ws + * - console_session: path + one-time jti consumed upstream + * - pilot_tunnel: machine credential for agent loopback; no further path gate + * (must stay allowed so hub-forwarded /ws still works on pilot agents) + */ +const GENERIC_WS_ALLOWED_SCOPES = new Set([ + 'api_token', + 'console_session', + 'pilot_tunnel', +]); + /** * Header the deploy/update/down routes carry the per-deploy correlation id on, * mirroring the `sessionId` the frontend sends in `{action:'connectTerminal'}`. @@ -53,12 +70,16 @@ export function handleGenericWs( if (isProxyToken) return reject(socket, 403, 'Forbidden'); - // Admin enforcement: container exec requires admin role. - // console_session tokens are already admin-gated at creation time and - // path/jti-gated in upgradeHandler. API tokens reaching this point have - // full-admin scope (read-only / deploy-only are blocked by the upgrade - // handler's scope gate). - if (!decoded.scope) { + // Admin enforcement for unscoped session JWTs: DB user must be admin. + // Scoped JWTs are deny-by-default via GENERIC_WS_ALLOWED_SCOPES (each + // allowed scope is reduced earlier in the upgrade pipeline, except + // pilot_tunnel which is the loopback machine credential itself). + if (decoded.scope) { + if (!GENERIC_WS_ALLOWED_SCOPES.has(decoded.scope)) { + console.warn('[Exec] Rejected scoped token on /ws:', decoded.scope); + return reject(socket, 403, 'Forbidden'); + } + } else { const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined; if (!execUser) { console.warn('[Exec] User account not found:', decoded.username); diff --git a/backend/src/websocket/upgradeHandler.ts b/backend/src/websocket/upgradeHandler.ts index 201ba34a..a10dd489 100644 --- a/backend/src/websocket/upgradeHandler.ts +++ b/backend/src/websocket/upgradeHandler.ts @@ -5,7 +5,7 @@ import jwt from 'jsonwebtoken'; import { DatabaseService, type UserRole } from '../services/DatabaseService'; import { LicenseService } from '../services/LicenseService'; import { NodeRegistry } from '../services/NodeRegistry'; -import { COOKIE_NAME } from '../helpers/constants'; +import { COOKIE_NAME, MFA_PENDING_SCOPE } from '../helpers/constants'; import { handlePilotTunnel } from './pilotTunnel'; import { handleMeshProxyTunnel } from './meshProxyTunnel'; import { handleNotificationsWs } from './notifications'; @@ -212,6 +212,22 @@ export function attachUpgrade( }; } + // Partial-auth (mfa_pending) and enroll-only (pilot_enroll) JWTs must not + // continue past shared cookie/Bearer auth. HTTP already rejects mfa_pending + // outside MFA routes; pilot_enroll is only valid on /api/pilot/tunnel + // (handled above). Reject here for every remaining WS path so handlers that + // do not re-check scope (logs, notifications, host console, etc.) cannot + // accept them. handleGenericWs used to treat any set scope as "already + // gated" and skip admin; that path is now deny-by-default too. + // pilot_tunnel is intentionally allowed: agent loopback injects it on every + // forwarded WS, including /ws container exec. + if ( + decoded.scope === MFA_PENDING_SCOPE + || decoded.scope === 'pilot_enroll' + ) { + return reject(socket, 403, 'Forbidden'); + } + const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`); const pathname = parsedUrl.pathname;