mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +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 path from 'path';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { setupWebSocket } from './websocket';
|
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 { requestLoggingMiddleware } from './middleware/request-logging-middleware';
|
||||||
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
|
import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes';
|
||||||
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
|
import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver';
|
||||||
@@ -41,8 +46,7 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({
|
const wss = new WebSocketServer({
|
||||||
server,
|
noServer: true,
|
||||||
path: '/ws',
|
|
||||||
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
|
maxPayload: 1024 * 1024, // 1MB hard limit to prevent DoS
|
||||||
perMessageDeflate: false, // Prevent zip bomb attacks
|
perMessageDeflate: false, // Prevent zip bomb attacks
|
||||||
});
|
});
|
||||||
@@ -66,7 +70,8 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
|||||||
app.use(requestLoggingMiddleware);
|
app.use(requestLoggingMiddleware);
|
||||||
|
|
||||||
// Session middleware (for dashboard auth)
|
// Session middleware (for dashboard auth)
|
||||||
app.use(createSessionMiddleware());
|
const sessionMiddleware = createSessionMiddleware();
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
// Auth middleware (protects API routes when enabled)
|
// Auth middleware (protects API routes when enabled)
|
||||||
app.use(authMiddleware);
|
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
|
// WebSocket connection handler + file watcher
|
||||||
const { cleanup: wsCleanup } = setupWebSocket(wss);
|
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 {
|
function formatListenError(error: NodeJS.ErrnoException, options: ServerOptions): string {
|
||||||
if (error.code === 'EADDRINUSE' && options.host) {
|
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`;
|
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.
|
* Session-based auth with httpOnly cookies for CCS dashboard.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { IncomingMessage } from 'http';
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import rateLimit from 'express-rate-limit';
|
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(
|
export function requireLocalAccessWhenAuthDisabled(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
|
|||||||
@@ -9,16 +9,29 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getDashboardAuthConfig } from '../../../src/config/unified-config-loader';
|
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';
|
import { runWithScopedConfigDir } from '../../../src/utils/config-manager';
|
||||||
|
|
||||||
describe('Dashboard Auth', () => {
|
describe('Dashboard Auth', () => {
|
||||||
let tempDir = '';
|
let tempDir = '';
|
||||||
|
let originalDashboardAuthEnabled: string | undefined;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-'));
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-dashboard-auth-'));
|
||||||
|
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
if (originalDashboardAuthEnabled === undefined) {
|
||||||
|
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
if (tempDir && fs.existsSync(tempDir)) {
|
if (tempDir && fs.existsSync(tempDir)) {
|
||||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
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', () => {
|
describe('rate limiting config', () => {
|
||||||
it('rate limit window is 15 minutes', () => {
|
it('rate limit window is 15 minutes', () => {
|
||||||
const windowMs = 15 * 60 * 1000;
|
const windowMs = 15 * 60 * 1000;
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ import { startServer } from '../../../src/web-server';
|
|||||||
|
|
||||||
const instances: Array<Awaited<ReturnType<typeof startServer>>> = [];
|
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 () => {
|
afterEach(async () => {
|
||||||
while (instances.length > 0) {
|
while (instances.length > 0) {
|
||||||
const instance = instances.pop();
|
const instance = instances.pop();
|
||||||
@@ -66,4 +93,24 @@ describe('startServer host binding', () => {
|
|||||||
expect(serverConfig?.middlewareMode).toBe(true);
|
expect(serverConfig?.middlewareMode).toBe(true);
|
||||||
expect(serverConfig?.hmr?.server).toBe(instance.server);
|
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