fix(security): protect dashboard WebSocket upgrades

This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 10:42:05 -04:00
committed by GitHub
parent 25e21d311c
commit f09cdfcf2b
4 changed files with 308 additions and 4 deletions
+73 -4
View File
@@ -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<ServerInstanc
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({
server,
path: '/ws',
noServer: true,
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
perMessageDeflate: false, // Prevent zip bomb attacks
});
@@ -66,7 +70,8 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
app.use(requestLoggingMiddleware);
// Session middleware (for dashboard auth)
app.use(createSessionMiddleware());
const sessionMiddleware = createSessionMiddleware();
app.use(sessionMiddleware);
// Auth middleware (protects API routes when enabled)
app.use(authMiddleware);
@@ -115,6 +120,46 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
});
}
server.on('upgrade', (request, socket, head) => {
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<ServerInstanc
});
}
function getUpgradePathname(requestUrl: string | undefined): string | null {
try {
return new URL(requestUrl ?? '/', 'http://localhost').pathname;
} catch {
return null;
}
}
function rejectWebSocketUpgrade(
socket: NodeJS.WritableStream & { destroy: () => 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`;
@@ -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,
@@ -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<string, string> = {}
) {
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;
@@ -5,6 +5,33 @@ import { startServer } from '../../../src/web-server';
const instances: Array<Awaited<ReturnType<typeof startServer>>> = [];
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<ReturnType<typeof startServer>>, 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);
});
});