mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 10:18:14 +00:00
fix(security): protect dashboard WebSocket upgrades
This commit is contained in:
1 parent
25e21d311c
commit
f09cdfcf2b
4 files changed
+308
-4
No files matched your search
@@ -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