From f09cdfcf2ba0b14ffd20f69a1c0923343f245a39 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 10:42:05 -0400 Subject: [PATCH] fix(security): protect dashboard WebSocket upgrades --- src/web-server/index.ts | 77 ++++++++++++- src/web-server/middleware/auth-middleware.ts | 85 +++++++++++++++ tests/unit/web-server/auth-middleware.test.ts | 103 ++++++++++++++++++ .../unit/web-server/start-server-host.test.ts | 47 ++++++++ 4 files changed, 308 insertions(+), 4 deletions(-) diff --git a/src/web-server/index.ts b/src/web-server/index.ts index c00b5ae5..18058771 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -11,7 +11,12 @@ import http from 'http'; import path from 'path'; import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; -import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; +import { + authMiddleware, + createSessionMiddleware, + getDashboardWebSocketRejectionStatus, + isDashboardWebSocketUpgradeAllowed, +} from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes'; import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver'; @@ -41,8 +46,7 @@ export async function startServer(options: ServerOptions): Promise { + const pathname = getUpgradePathname(request.url); + if (!pathname) { + rejectWebSocketUpgrade(socket, 400, 'Invalid WebSocket upgrade request'); + return; + } + + if (pathname !== '/ws') { + if (!options.dev) { + rejectWebSocketUpgrade(socket, 404, 'WebSocket endpoint not found'); + } + return; + } + + const response = new http.ServerResponse(request); + sessionMiddleware( + request as express.Request, + response as express.Response, + (error?: unknown) => { + if (error) { + rejectWebSocketUpgrade(socket, 500, 'WebSocket session validation failed'); + return; + } + + if (!isDashboardWebSocketUpgradeAllowed(request)) { + rejectWebSocketUpgrade( + socket, + getDashboardWebSocketRejectionStatus(request), + 'WebSocket access denied' + ); + return; + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit('connection', ws, request); + }); + } + ); + }); + // WebSocket connection handler + file watcher const { cleanup: wsCleanup } = setupWebSocket(wss); @@ -178,6 +223,30 @@ export async function startServer(options: ServerOptions): Promise void }, + statusCode: 400 | 401 | 403 | 404 | 500, + message: string +): void { + socket.write( + `HTTP/1.1 ${statusCode} ${message}\r\n` + + 'Connection: close\r\n' + + 'Content-Type: text/plain; charset=utf-8\r\n' + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + '\r\n' + + message + ); + socket.destroy(); +} + function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string { if (error.code === 'EADDRINUSE' && options.host) { return `Unable to bind ${options.host}:${options.port}; the address may be unavailable or the port may already be in use`; diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 4afc4703..39228916 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -3,6 +3,7 @@ * Session-based auth with httpOnly cookies for CCS dashboard. */ +import type { IncomingMessage } from 'http'; import type { NextFunction, Request, Response } from 'express'; import session from 'express-session'; import rateLimit from 'express-rate-limit'; @@ -151,6 +152,90 @@ export function isLoopbackRemoteAddress(value: string | undefined): boolean { ); } +function isLoopbackHostname(value: string | undefined): boolean { + if (!value) return false; + const normalized = value + .trim() + .toLowerCase() + .replace(/^\[|\]$/g, ''); + return ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + isLoopbackRemoteAddress(normalized) + ); +} + +function getSingleHeader(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function parseHostHeader(value: string | undefined): URL | null { + if (!value) return null; + + try { + return new URL(`http://${value}`); + } catch { + return null; + } +} + +function isHttpOrigin(origin: URL): boolean { + return origin.protocol === 'http:' || origin.protocol === 'https:'; +} + +export function isDashboardWebSocketOriginAllowed(req: IncomingMessage): boolean { + const originHeader = getSingleHeader(req.headers.origin); + if (!originHeader) return true; + + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + return false; + } + + if (!isHttpOrigin(origin)) { + return false; + } + + const host = parseHostHeader(getSingleHeader(req.headers.host)); + if (!host) { + return false; + } + + if (origin.host.toLowerCase() === host.host.toLowerCase()) { + return true; + } + + return ( + isLoopbackHostname(origin.hostname) && + isLoopbackHostname(host.hostname) && + origin.port === host.port + ); +} + +export function isDashboardWebSocketUpgradeAllowed(req: IncomingMessage): boolean { + if (!isDashboardWebSocketOriginAllowed(req)) { + return false; + } + + if (!isDashboardAuthEnabled()) { + return isLoopbackRemoteAddress(req.socket.remoteAddress); + } + + return Boolean((req as Request).session?.authenticated); +} + +export function getDashboardWebSocketRejectionStatus(req?: IncomingMessage): 401 | 403 { + if (req && !isDashboardWebSocketOriginAllowed(req)) { + return 403; + } + + if (!isDashboardAuthEnabled()) return 403; + + return 401; +} + export function requireLocalAccessWhenAuthDisabled( req: Request, res: Response, diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index 5e3a30e3..a252cda2 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -9,16 +9,29 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader'; +import { + isDashboardWebSocketOriginAllowed, + getDashboardWebSocketRejectionStatus, + isDashboardWebSocketUpgradeAllowed, +} from '../../../src/web-server/middleware/auth-middleware'; import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; describe('Dashboard Auth', () => { let tempDir = ''; + let originalDashboardAuthEnabled: string | undefined; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-')); + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; }); afterEach(() => { + if (originalDashboardAuthEnabled === undefined) { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } else { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } + if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -149,6 +162,96 @@ describe('Dashboard Auth', () => { }); }); + describe('websocket upgrade access', () => { + function makeUpgradeRequest( + remoteAddress: string, + authenticated = false, + headers: Record = {} + ) { + return { + headers, + socket: { remoteAddress }, + session: authenticated ? { authenticated: true } : { authenticated: false }, + } as never; + } + + it('allows loopback websocket upgrades when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('::1'))).toBe(true); + }); + + it('blocks remote websocket upgrades when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + const request = makeUpgradeRequest('10.10.0.24'); + + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus()).toBe(403); + }); + + it('blocks cross-site websocket origins when dashboard auth is disabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + const request = makeUpgradeRequest('127.0.0.1', false, { + host: '127.0.0.1:3001', + origin: 'https://evil.example.test', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(false); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus(request)).toBe(403); + }); + + it('requires an authenticated session for websocket upgrades when auth is enabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + + expect(isDashboardWebSocketUpgradeAllowed(makeUpgradeRequest('127.0.0.1'))).toBe(false); + expect( + isDashboardWebSocketUpgradeAllowed( + makeUpgradeRequest('10.10.0.24', true, { + host: 'dashboard.internal:3001', + origin: 'https://dashboard.internal:3001', + }) + ) + ).toBe(true); + expect(getDashboardWebSocketRejectionStatus()).toBe(401); + }); + + it('allows same-origin websocket upgrades when auth is enabled', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('10.10.0.24', true, { + host: 'dashboard.example.test:3001', + origin: 'https://dashboard.example.test:3001', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true); + }); + + it('allows loopback host aliases on the same dashboard port', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('127.0.0.1', true, { + host: '127.0.0.1:3001', + origin: 'http://localhost:3001', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(true); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true); + }); + + it('blocks cross-site websocket origins even with an authenticated session', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; + const request = makeUpgradeRequest('127.0.0.1', true, { + host: '127.0.0.1:3001', + origin: 'https://evil.example.test', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(false); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus(request)).toBe(403); + }); + }); + describe('rate limiting config', () => { it('rate limit window is 15 minutes', () => { const windowMs = 15 * 60 * 1000; diff --git a/tests/unit/web-server/start-server-host.test.ts b/tests/unit/web-server/start-server-host.test.ts index e0a48464..0d4313a6 100644 --- a/tests/unit/web-server/start-server-host.test.ts +++ b/tests/unit/web-server/start-server-host.test.ts @@ -5,6 +5,33 @@ import { startServer } from '../../../src/web-server'; const instances: Array>> = []; +class MockUpgradeSocket { + data = ''; + destroyed = false; + + write(chunk: string | Buffer): boolean { + this.data += chunk.toString(); + return true; + } + + destroy(): void { + this.destroyed = true; + } +} + +function dispatchUpgrade(instance: Awaited>, url: string) { + const listener = instance.server.listeners('upgrade')[0] as ( + request: unknown, + socket: MockUpgradeSocket, + head: Buffer + ) => void; + const socket = new MockUpgradeSocket(); + + listener({ url, headers: {}, socket: { remoteAddress: '127.0.0.1' } }, socket, Buffer.alloc(0)); + + return socket; +} + afterEach(async () => { while (instances.length > 0) { const instance = instances.pop(); @@ -66,4 +93,24 @@ describe('startServer host binding', () => { expect(serverConfig?.middlewareMode).toBe(true); expect(serverConfig?.hmr?.server).toBe(instance.server); }); + + it('rejects unsupported production websocket upgrade paths', async () => { + const instance = await startServer({ port: 0 }); + instances.push(instance); + + const socket = dispatchUpgrade(instance, '/vite-hmr'); + + expect(socket.data.startsWith('HTTP/1.1 404')).toBe(true); + expect(socket.destroyed).toBe(true); + }); + + it('rejects malformed websocket upgrade targets', async () => { + const instance = await startServer({ port: 0 }); + instances.push(instance); + + const socket = dispatchUpgrade(instance, 'http://[bad'); + + expect(socket.data.startsWith('HTTP/1.1 400')).toBe(true); + expect(socket.destroyed).toBe(true); + }); });