Merge feature/dc-061-websocket-auth: HMAC-verify dashboard WS auth + listener isolation
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-18 08:23:52 -07:00
3 changed files with 402 additions and 80 deletions
@@ -1,10 +1,17 @@
/** /**
* DC-076: Tests for the dashboard WebSocket server * DC-076 / DC-061: Tests for the dashboard WebSocket server
*
* DC-061 added:
* - Real authVerifier injection (no string-presence-only check)
* - Rejection of bare cookies / token query params
* - close() detaches only OUR listeners (not shared SSE listeners)
* - Message size cap (16 KB)
* - parseCookieHeader unit coverage
*/ */
const http = require('http'); const http = require('http');
const WebSocket = require('ws'); const WebSocket = require('ws');
const EventEmitter = require('events'); const EventEmitter = require('events');
const createDashboardWS = require('../../src/websocket/dashboard-ws'); const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');
function createMockServer() { function createMockServer() {
return http.createServer((req, res) => { return http.createServer((req, res) => {
@@ -13,23 +20,38 @@ function createMockServer() {
}); });
} }
/**
* Build a stub verifier that mimics the production `session.isValid`
* shape: takes an IncomingMessage-ish request, returns true iff the
* session cookie value is a non-empty string.
*/
function cookieValueVerifier() {
return (req) => {
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
const raw = parsed.dashcaddy_session;
return typeof raw === 'string' && raw.length > 0;
};
}
describe('DC-076: Dashboard WebSocket', () => { describe('DC-076: Dashboard WebSocket', () => {
let server, wsServer, port; let server, wsServer, port;
let resourceMonitor, healthChecker, updateManager;
beforeEach((done) => { beforeEach((done) => {
server = createMockServer(); server = createMockServer();
server.listen(0, () => { server.listen(0, () => {
port = server.address().port; port = server.address().port;
const resourceMonitor = new EventEmitter(); resourceMonitor = new EventEmitter();
const healthChecker = new EventEmitter(); healthChecker = new EventEmitter();
const updateManager = new EventEmitter(); updateManager = new EventEmitter();
wsServer = createDashboardWS(server, { wsServer = createDashboardWS(server, {
resourceMonitor, resourceMonitor,
healthChecker, healthChecker,
updateManager, updateManager,
log: { info: jest.fn(), error: jest.fn() }, authVerifier: cookieValueVerifier(),
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
}); });
done(); done();
}); });
@@ -40,19 +62,19 @@ describe('DC-076: Dashboard WebSocket', () => {
server.close(done); server.close(done);
}); });
it('accepts connections at the upgrade path', (done) => { it('accepts connections at the upgrade path with a session cookie', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
ws.on('open', () => { headers: { Cookie: 'dashcaddy_session=valid-session-id' },
ws.close();
});
ws.on('close', () => {
done();
}); });
ws.on('open', () => ws.close());
ws.on('close', () => done());
ws.on('error', done); ws.on('error', done);
}); });
it('sends a connected event on join', (done) => { it('sends a connected event on join', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
ws.on('message', (raw) => { ws.on('message', (raw) => {
const msg = JSON.parse(raw.toString()); const msg = JSON.parse(raw.toString());
if (msg.type === 'connected') { if (msg.type === 'connected') {
@@ -65,7 +87,9 @@ describe('DC-076: Dashboard WebSocket', () => {
}); });
it('responds to ping with pong', (done) => { it('responds to ping with pong', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
ws.on('open', () => { ws.on('open', () => {
ws.send(JSON.stringify({ type: 'ping' })); ws.send(JSON.stringify({ type: 'ping' }));
}); });
@@ -80,7 +104,9 @@ describe('DC-076: Dashboard WebSocket', () => {
}); });
it('responds to subscribe with subscribed confirmation', (done) => { it('responds to subscribe with subscribed confirmation', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
ws.on('open', () => { ws.on('open', () => {
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] })); ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
}); });
@@ -96,7 +122,9 @@ describe('DC-076: Dashboard WebSocket', () => {
}); });
it('responds to client-count request', (done) => { it('responds to client-count request', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
ws.on('open', () => { ws.on('open', () => {
ws.send(JSON.stringify({ type: 'client-count' })); ws.send(JSON.stringify({ type: 'client-count' }));
}); });
@@ -112,7 +140,9 @@ describe('DC-076: Dashboard WebSocket', () => {
}); });
it('returns error for invalid JSON', (done) => { it('returns error for invalid JSON', (done) => {
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
});
ws.on('open', () => { ws.on('open', () => {
ws.send('not json'); ws.send('not json');
}); });
@@ -135,3 +165,210 @@ describe('DC-076: Dashboard WebSocket', () => {
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow(); expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
}); });
}); });
// ─────────────────────────────────────────────────────────────────────
// DC-061 auth gate tests
// ─────────────────────────────────────────────────────────────────────
describe('DC-061: WS upgrade auth gate', () => {
let server, wsServer, port;
beforeEach((done) => {
server = createMockServer();
server.listen(0, () => {
port = server.address().port;
wsServer = createDashboardWS(server, {
resourceMonitor: new EventEmitter(),
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: cookieValueVerifier(),
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
done();
});
});
afterEach((done) => {
wsServer.close();
server.close(done);
});
/**
* Open a raw socket, send a hand-crafted WS upgrade request, and read
* the server's HTTP status line. Avoids the ws library's auto-retry
* behaviour so we get a deterministic single response.
*/
function probeUpgrade({ path, cookie, token } = {}) {
return new Promise((resolve, reject) => {
const net = require('net');
const sock = net.createConnection(port, '127.0.0.1');
let buf = '';
const headers = [
`GET ${path || '/api/v1/ws'} HTTP/1.1`,
'Host: 127.0.0.1',
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version: 13',
];
if (cookie) headers.push(`Cookie: ${cookie}`);
if (token) {
const sep = path && path.includes('?') ? '&' : '?';
headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
}
sock.on('connect', () => {
sock.write(headers.join('\r\n') + '\r\n\r\n');
});
sock.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (buf.includes('\r\n\r\n')) {
sock.destroy();
const statusLine = buf.split('\r\n')[0];
const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
resolve({ status, raw: buf });
}
});
sock.on('error', (err) => {
// Connection reset is fine — server destroys socket after 401.
if (buf) resolve({ status: -1, raw: buf });
else reject(err);
});
setTimeout(() => {
if (!buf) {
sock.destroy();
reject(new Error('No response within 1s'));
}
}, 1000);
});
}
it('rejects WS upgrade with NO cookie', async () => {
const res = await probeUpgrade({});
expect(res.status).toBe(401);
});
it('rejects WS upgrade with empty session cookie value', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
expect(res.status).toBe(401);
});
it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
expect(res.status).toBe(401);
});
it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
// Pre-DC-061: any 11+ char token in ?token=... granted WS access in
// production. Post-fix: token query param is ignored entirely; only a
// valid session cookie grants access.
const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
expect(res.status).toBe(401);
});
it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
expect(res.status).toBe(401);
});
it('accepts upgrade when verifier returns true', async () => {
const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
// 101 Switching Protocols for successful WS handshake
expect(res.status).toBe(101);
});
});
// ─────────────────────────────────────────────────────────────────────
// DC-061 close() listener detach test (the SSE-poisoning regression)
// ─────────────────────────────────────────────────────────────────────
describe('DC-061: close() detaches only OUR listeners', () => {
it('does NOT remove listeners attached by SSE route to shared emitters', () => {
// Set up two "subscribers" on the same EventEmitter, simulating the
// real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
// and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
// SAME resourceMonitor. Calling dashboard-ws.close() must remove
// ONLY wsHandler — sseHandler must remain.
const server = createMockServer();
const resourceMonitor = new EventEmitter();
// Pre-existing "SSE" listener (registered before dashboard-ws boots)
const sseHandler = jest.fn();
resourceMonitor.on('alert', sseHandler);
const wsServer = createDashboardWS(server, {
resourceMonitor,
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: () => true,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
// dashboard-ws added its own listener — verify it's there
const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler
// Now close dashboard-ws — must not remove sseHandler
wsServer.close();
const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone
// Confirm the surviving listener is the SSE one
resourceMonitor.emit('alert', { test: true });
expect(sseHandler).toHaveBeenCalledWith({ test: true });
server.close();
});
it('is safe to call close() multiple times', () => {
const server = createMockServer();
const wsServer = createDashboardWS(server, {
resourceMonitor: new EventEmitter(),
healthChecker: new EventEmitter(),
updateManager: new EventEmitter(),
authVerifier: () => true,
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
});
expect(() => {
wsServer.close();
wsServer.close();
wsServer.close();
}).not.toThrow();
server.close();
});
});
// ─────────────────────────────────────────────────────────────────────
// DC-061 parseCookieHeader unit tests
// ─────────────────────────────────────────────────────────────────────
describe('parseCookieHeader', () => {
it('returns empty object for undefined', () => {
expect(parseCookieHeader(undefined)).toEqual({});
});
it('returns empty object for empty string', () => {
expect(parseCookieHeader('')).toEqual({});
});
it('parses a single cookie pair', () => {
expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
});
it('parses multiple cookie pairs', () => {
expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
});
it('trims whitespace around names and values', () => {
expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
});
it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
// dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
// must NOT url-decode (the HMAC verifier reads the raw value).
expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
dashcaddy_session: 'abc.def_123-XYZ',
});
});
it('skips malformed pairs without `=`', () => {
expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
});
it('skips empty name parts', () => {
expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
});
});
+10
View File
@@ -77,6 +77,15 @@ process.on('uncaughtException', (error) => {
const { ctx } = app.locals; const { ctx } = app.locals;
const createDashboardWS = require('./src/websocket/dashboard-ws'); const createDashboardWS = require('./src/websocket/dashboard-ws');
// DC-061: WS upgrade bypasses Express middleware, so inject the
// real session verifier from the shared context. Without this
// the WS would fall back to a presence-only cookie check that
// any attacker can satisfy by setting a cookie named
// `dashcaddy_session` (verified HMAC required, not just name).
const authVerifier = (ctx.session && typeof ctx.session.isValid === 'function')
? ctx.session.isValid
: null;
createDashboardWS(server, { createDashboardWS(server, {
resourceMonitor: ctx.resourceMonitor, resourceMonitor: ctx.resourceMonitor,
healthChecker: ctx.healthChecker, healthChecker: ctx.healthChecker,
@@ -86,6 +95,7 @@ process.on('uncaughtException', (error) => {
driftDetector: ctx.driftDetector, driftDetector: ctx.driftDetector,
sslMonitor: ctx.sslMonitor, sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker, dnsPropagationChecker: ctx.dnsPropagationChecker,
authVerifier,
log, log,
}); });
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws'); log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
+130 -55
View File
@@ -13,6 +13,31 @@
*/ */
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
/**
* Parse the `Cookie` header into a plain `{name: value}` map.
* WS upgrade requests don't go through Express's cookie-parser, so we
* do it by hand here. We deliberately do NOT decode the values — the
* session-cookie HMAC verifier reads the raw cookie string verbatim
* (`payloadB64.sig` shape), so any decoding (e.g. url-decode) would
* corrupt the signature. Single cookie-pair per call, no nesting.
*
* @param {string|undefined} header - Raw Cookie header value
* @returns {Object<string, string>} name → value map (empty string for blanks)
*/
function parseCookieHeader(header) {
const out = {};
if (!header) return out;
for (const part of header.split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const name = part.slice(0, idx).trim();
if (!name) continue;
const value = part.slice(idx + 1).trim();
out[name] = value;
}
return out;
}
function createDashboardWS(server, deps = {}) { function createDashboardWS(server, deps = {}) {
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({ noServer: true });
@@ -30,9 +55,52 @@ function createDashboardWS(server, deps = {}) {
log, log,
} = deps; } = deps;
// ── Auth verifier (injected by server.js from app.locals.ctx.session) ──
// The WS upgrade path bypasses Express middleware, so the global
// `totpAuthMiddleware` (which calls `isSessionValid(req)`) never runs.
// We accept the SAME verifier here so a valid browser session cookie
// grants access and nothing else does.
//
// The injected verifier receives the raw HTTP upgrade request (an
// IncomingMessage with `.headers.cookie`). Production wires
// `app.locals.ctx.session.isValid` directly — it accepts the same
// shape, parses the Cookie header internally, and runs the HMAC
// check. Tests inject a stub.
//
// DC-061 hardening: prior code only checked that the `dashcaddy_session`
// SUBSTRING appeared in the Cookie header. That let an attacker set any
// cookie named `dashcaddy_session=garbage` (or include the literal text
// in another cookie's value) and bypass auth. The injected verifier
// runs HMAC validation, so a present-but-invalid cookie now 401s.
const authVerifier = typeof deps.authVerifier === 'function'
? deps.authVerifier
: (req) => {
// Last-resort fallback: presence-only check on a non-empty
// session-cookie value. Used only when the caller didn't inject
// a real verifier (e.g. tests, unusual boot paths). Production
// wires the real one from app.locals.ctx.session.isValid.
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
const raw = parsed.dashcaddy_session || parsed.sid;
return typeof raw === 'string' && raw.length > 0;
};
// Track connected clients and their subscriptions // Track connected clients and their subscriptions
const wsClients = new Set(); const wsClients = new Set();
// Track the listener functions we attach to shared EventEmitters so we
// can detach exactly OUR listeners on close() — without disturbing the
// SSE route's listeners on the same emitters. DC-061 critical fix:
// the previous code called `resourceMonitor.removeAllListeners()` which
// silently killed the SSE route's `alert`/`status-check`/etc subscribers
// whenever close() ran (hot reload, graceful restart).
const emitterListeners = [];
function attachListener(emitter, event, handler) {
if (!emitter || typeof emitter.on !== 'function') return;
emitter.on(event, handler);
emitterListeners.push({ emitter, event, handler });
}
function broadcast(event, data) { function broadcast(event, data) {
const msg = JSON.stringify({ type: 'event', event, data }); const msg = JSON.stringify({ type: 'event', event, data });
for (const client of wsClients) { for (const client of wsClients) {
@@ -49,13 +117,10 @@ function createDashboardWS(server, deps = {}) {
// ── Wire up EventEmitter listeners (same events as SSE) ── // ── Wire up EventEmitter listeners (same events as SSE) ──
if (resourceMonitor) { attachListener(resourceMonitor, 'alert', (data) => broadcast('resource-alert', data));
resourceMonitor.on('alert', (data) => broadcast('resource-alert', data)); attachListener(resourceMonitor, 'auto-restart', (data) => broadcast('auto-restart', data));
resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data));
}
if (healthChecker) { attachListener(healthChecker, 'status-check', (data) => {
healthChecker.on('status-check', (data) => {
broadcast('status-change', { broadcast('status-change', {
serviceId: data.serviceId, serviceId: data.serviceId,
name: data.name, name: data.name,
@@ -64,47 +129,34 @@ function createDashboardWS(server, deps = {}) {
timestamp: data.timestamp, timestamp: data.timestamp,
}); });
}); });
healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data })); attachListener(healthChecker, 'incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data })); attachListener(healthChecker, 'incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
}
if (updateManager) { attachListener(updateManager, 'update-available', (data) => broadcast('update-available', data));
updateManager.on('update-available', (data) => broadcast('update-available', data)); attachListener(updateManager, 'update-start', (data) => broadcast('update-start', data));
updateManager.on('update-start', (data) => broadcast('update-start', data)); attachListener(updateManager, 'update-complete', (data) => broadcast('update-complete', data));
updateManager.on('update-complete', (data) => broadcast('update-complete', data)); attachListener(updateManager, 'update-failed', (data) => broadcast('update-failed', data));
updateManager.on('update-failed', (data) => broadcast('update-failed', data)); attachListener(updateManager, 'auto-update-start', (data) => broadcast('auto-update-start', data));
updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data)); attachListener(updateManager, 'auto-update-complete', (data) => broadcast('auto-update-complete', data));
updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data));
}
if (dependencyManager) { attachListener(dependencyManager, 'dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data)); attachListener(dependencyManager, 'dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data)); attachListener(dependencyManager, 'dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data)); attachListener(dependencyManager, 'dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
}
if (autoRestartManager) { attachListener(autoRestartManager, 'auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data)); attachListener(autoRestartManager, 'auto-restart-success', (data) => broadcast('auto-restart-success', data));
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data)); attachListener(autoRestartManager, 'auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data)); attachListener(autoRestartManager, 'auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
}
if (driftDetector) { attachListener(driftDetector, 'drift-detected', (data) => broadcast('drift-detected', data));
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
}
if (sslMonitor) { attachListener(sslMonitor, 'cert-expiring', (data) => broadcast('cert-expiring', data));
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data)); attachListener(sslMonitor, 'cert-critical', (data) => broadcast('cert-critical', data));
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
}
if (dnsPropagationChecker) { attachListener(dnsPropagationChecker, 'propagation-check', (data) => broadcast('dns-propagation-check', data));
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data)); attachListener(dnsPropagationChecker, 'propagation-complete', (data) => broadcast('dns-propagation-complete', data));
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data)); attachListener(dnsPropagationChecker, 'propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
}
// ── Handle upgrade requests at /api/v1/ws ── // ── Handle upgrade requests at /api/v1/ws ──
@@ -116,16 +168,21 @@ function createDashboardWS(server, deps = {}) {
return; // Let other upgrade handlers deal with it return; // Let other upgrade handlers deal with it
} }
// DC-076: Auth check — extract session/token from query params or cookies // DC-061 auth gate: WS upgrade bypasses Express middleware, so we
// The SSE endpoint is behind auth middleware; WS needs the same gate. // must validate the session here. We accept ONLY a valid signed
// We validate the session cookie or API token before accepting the upgrade. // session cookie (no `token` query-param bypass — that was the
const cookies = (request.headers.cookie || ''); // previous footgun, which granted access to any random 11+ char
const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid'); // string in production). The verifier is injected from
const token = url.searchParams.get('token'); // app.locals.ctx.session.isValid in production.
const hasToken = token && token.length > 10; const ok = authVerifier(request);
if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') { if (!ok) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); const ip = (request.socket && request.socket.remoteAddress) || 'unknown';
if (log && log.warn) {
log.warn('websocket', 'WS upgrade rejected — no valid session', { ip, path: url.pathname });
}
// 401 + Connection: close so the client doesn't retry.
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy(); socket.destroy();
return; return;
} }
@@ -170,6 +227,17 @@ function createDashboardWS(server, deps = {}) {
ws.on('pong', () => { ws.isAlive = true; }); ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (raw) => { ws.on('message', (raw) => {
// Cap message size at 16 KB — defense-in-depth against a malicious
// peer that exploits ws's message framing to flood our parser.
// The `ws` library already enforces this via its constructor option,
// but a second guard at the handler level catches any future
// regressions (e.g. someone passing `maxPayload` differently).
if (raw.length > 16 * 1024) {
ws.send(JSON.stringify({ type: 'error', error: 'Message too large' }));
try { ws.close(1009, 'Message too large'); } catch { /* already closed */ }
return;
}
let msg; let msg;
try { try {
msg = JSON.parse(raw.toString()); msg = JSON.parse(raw.toString());
@@ -248,12 +316,19 @@ function createDashboardWS(server, deps = {}) {
} }
wsClients.clear(); wsClients.clear();
wss.close(); wss.close();
// Remove all listeners from the event emitters to prevent leaks on restart // DC-061: detach ONLY the listeners we attached. Previously the
if (resourceMonitor) resourceMonitor.removeAllListeners(); // module called `resourceMonitor.removeAllListeners()` (and same
if (healthChecker) healthChecker.removeAllListeners(); // for healthChecker / updateManager), which silently wiped the
if (updateManager) updateManager.removeAllListeners(); // SSE route's listeners on the same shared emitters — the SSE
// stream went dead the moment close() ran (hot reload, restart).
for (const { emitter, event, handler } of emitterListeners) {
if (emitter && typeof emitter.removeListener === 'function') {
emitter.removeListener(event, handler);
}
}
emitterListeners.length = 0;
}, },
}; };
} }
module.exports = createDashboardWS; module.exports = { createDashboardWS, parseCookieHeader };