mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
fix(security): protect dashboard WebSocket upgrades
This commit is contained in:
+73
-4
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user