diff --git a/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js b/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js index 855ee38..6efa5ce 100644 --- a/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js +++ b/dashcaddy-api/__tests__/websocket/dashboard-ws.test.js @@ -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 WebSocket = require('ws'); const EventEmitter = require('events'); -const createDashboardWS = require('../../src/websocket/dashboard-ws'); +const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws'); function createMockServer() { 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', () => { let server, wsServer, port; + let resourceMonitor, healthChecker, updateManager; beforeEach((done) => { server = createMockServer(); server.listen(0, () => { port = server.address().port; - const resourceMonitor = new EventEmitter(); - const healthChecker = new EventEmitter(); - const updateManager = new EventEmitter(); + resourceMonitor = new EventEmitter(); + healthChecker = new EventEmitter(); + updateManager = new EventEmitter(); wsServer = createDashboardWS(server, { resourceMonitor, healthChecker, updateManager, - log: { info: jest.fn(), error: jest.fn() }, + authVerifier: cookieValueVerifier(), + log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() }, }); done(); }); @@ -40,19 +62,19 @@ describe('DC-076: Dashboard WebSocket', () => { server.close(done); }); - it('accepts connections at the upgrade path', (done) => { - const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`); - ws.on('open', () => { - ws.close(); - }); - ws.on('close', () => { - done(); + it('accepts connections at the upgrade path with a session cookie', (done) => { + const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, { + headers: { Cookie: 'dashcaddy_session=valid-session-id' }, }); + ws.on('open', () => ws.close()); + ws.on('close', () => done()); ws.on('error', 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) => { const msg = JSON.parse(raw.toString()); if (msg.type === 'connected') { @@ -65,7 +87,9 @@ describe('DC-076: Dashboard WebSocket', () => { }); 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.send(JSON.stringify({ type: 'ping' })); }); @@ -80,7 +104,9 @@ describe('DC-076: Dashboard WebSocket', () => { }); 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.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) => { - 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.send(JSON.stringify({ type: 'client-count' })); }); @@ -112,7 +140,9 @@ describe('DC-076: Dashboard WebSocket', () => { }); 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.send('not json'); }); @@ -135,3 +165,210 @@ describe('DC-076: Dashboard WebSocket', () => { 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 `.` — 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' }); + }); +}); diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 986908f..ce98c62 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -77,6 +77,15 @@ process.on('uncaughtException', (error) => { const { ctx } = app.locals; 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, { resourceMonitor: ctx.resourceMonitor, healthChecker: ctx.healthChecker, @@ -86,6 +95,7 @@ process.on('uncaughtException', (error) => { driftDetector: ctx.driftDetector, sslMonitor: ctx.sslMonitor, dnsPropagationChecker: ctx.dnsPropagationChecker, + authVerifier, log, }); log.info('server', 'Dashboard WebSocket attached at /api/v1/ws'); diff --git a/dashcaddy-api/src/websocket/dashboard-ws.js b/dashcaddy-api/src/websocket/dashboard-ws.js index abdcc94..95fbb56 100644 --- a/dashcaddy-api/src/websocket/dashboard-ws.js +++ b/dashcaddy-api/src/websocket/dashboard-ws.js @@ -13,6 +13,31 @@ */ 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} 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 = {}) { const wss = new WebSocketServer({ noServer: true }); @@ -30,9 +55,52 @@ function createDashboardWS(server, deps = {}) { log, } = 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 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) { const msg = JSON.stringify({ type: 'event', event, data }); for (const client of wsClients) { @@ -49,62 +117,46 @@ function createDashboardWS(server, deps = {}) { // ── Wire up EventEmitter listeners (same events as SSE) ── - if (resourceMonitor) { - resourceMonitor.on('alert', (data) => broadcast('resource-alert', data)); - resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data)); - } + attachListener(resourceMonitor, 'alert', (data) => broadcast('resource-alert', data)); + attachListener(resourceMonitor, 'auto-restart', (data) => broadcast('auto-restart', data)); - if (healthChecker) { - healthChecker.on('status-check', (data) => { - broadcast('status-change', { - serviceId: data.serviceId, - name: data.name, - status: data.status, - responseTime: data.responseTime, - timestamp: data.timestamp, - }); + attachListener(healthChecker, 'status-check', (data) => { + broadcast('status-change', { + serviceId: data.serviceId, + name: data.name, + status: data.status, + responseTime: data.responseTime, + timestamp: data.timestamp, }); - healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data })); - healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data })); - } + }); + attachListener(healthChecker, 'incident-created', (data) => broadcast('incident', { type: 'created', ...data })); + attachListener(healthChecker, 'incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data })); - if (updateManager) { - updateManager.on('update-available', (data) => broadcast('update-available', data)); - updateManager.on('update-start', (data) => broadcast('update-start', data)); - updateManager.on('update-complete', (data) => broadcast('update-complete', data)); - updateManager.on('update-failed', (data) => broadcast('update-failed', data)); - updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data)); - updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data)); - } + attachListener(updateManager, 'update-available', (data) => broadcast('update-available', data)); + attachListener(updateManager, 'update-start', (data) => broadcast('update-start', data)); + attachListener(updateManager, 'update-complete', (data) => broadcast('update-complete', data)); + attachListener(updateManager, 'update-failed', (data) => broadcast('update-failed', data)); + attachListener(updateManager, 'auto-update-start', (data) => broadcast('auto-update-start', data)); + attachListener(updateManager, 'auto-update-complete', (data) => broadcast('auto-update-complete', data)); - if (dependencyManager) { - dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data)); - dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data)); - dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data)); - dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data)); - } + attachListener(dependencyManager, 'dependency-restart-start', (data) => broadcast('dependency-restart-start', data)); + attachListener(dependencyManager, 'dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data)); + attachListener(dependencyManager, 'dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data)); + attachListener(dependencyManager, 'dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data)); - if (autoRestartManager) { - autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data)); - autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data)); - autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data)); - autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data)); - } + attachListener(autoRestartManager, 'auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data)); + attachListener(autoRestartManager, 'auto-restart-success', (data) => broadcast('auto-restart-success', data)); + attachListener(autoRestartManager, 'auto-restart-failed', (data) => broadcast('auto-restart-failed', data)); + attachListener(autoRestartManager, 'auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data)); - if (driftDetector) { - driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data)); - } + attachListener(driftDetector, 'drift-detected', (data) => broadcast('drift-detected', data)); - if (sslMonitor) { - sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data)); - sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data)); - } + attachListener(sslMonitor, 'cert-expiring', (data) => broadcast('cert-expiring', data)); + attachListener(sslMonitor, 'cert-critical', (data) => broadcast('cert-critical', data)); - if (dnsPropagationChecker) { - dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data)); - dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data)); - dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data)); - } + attachListener(dnsPropagationChecker, 'propagation-check', (data) => broadcast('dns-propagation-check', data)); + attachListener(dnsPropagationChecker, 'propagation-complete', (data) => broadcast('dns-propagation-complete', data)); + attachListener(dnsPropagationChecker, 'propagation-timeout', (data) => broadcast('dns-propagation-timeout', data)); // ── Handle upgrade requests at /api/v1/ws ── @@ -116,16 +168,21 @@ function createDashboardWS(server, deps = {}) { return; // Let other upgrade handlers deal with it } - // DC-076: Auth check — extract session/token from query params or cookies - // The SSE endpoint is behind auth middleware; WS needs the same gate. - // We validate the session cookie or API token before accepting the upgrade. - const cookies = (request.headers.cookie || ''); - const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid'); - const token = url.searchParams.get('token'); - const hasToken = token && token.length > 10; + // DC-061 auth gate: WS upgrade bypasses Express middleware, so we + // must validate the session here. We accept ONLY a valid signed + // session cookie (no `token` query-param bypass — that was the + // previous footgun, which granted access to any random 11+ char + // string in production). The verifier is injected from + // app.locals.ctx.session.isValid in production. + const ok = authVerifier(request); - if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') { - socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + if (!ok) { + 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(); return; } @@ -170,6 +227,17 @@ function createDashboardWS(server, deps = {}) { ws.on('pong', () => { ws.isAlive = true; }); 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; try { msg = JSON.parse(raw.toString()); @@ -248,12 +316,19 @@ function createDashboardWS(server, deps = {}) { } wsClients.clear(); wss.close(); - // Remove all listeners from the event emitters to prevent leaks on restart - if (resourceMonitor) resourceMonitor.removeAllListeners(); - if (healthChecker) healthChecker.removeAllListeners(); - if (updateManager) updateManager.removeAllListeners(); + // DC-061: detach ONLY the listeners we attached. Previously the + // module called `resourceMonitor.removeAllListeners()` (and same + // for healthChecker / updateManager), which silently wiped the + // 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 };