[glm-grade=A] fix(websocket): HMAC-verify dashboard WS auth + listener isolation (DC-061)
Pre-fix: /api/v1/ws checked cookies.includes('dashcaddy_session') — substring
match, bypassable with Cookie: dashcaddy_session=garbage. Production also
accepted any 11+ char ?token= query string. Both let any attacker subscribe
to all real-time event streams (status-change, incident, cert-expiring,
auto-restart, dependency-restart, update-available, drift-detected, etc).
Fix (3 files, +404/-81):
(1) server.js:80-99 wires ctx.session.isValid (HMAC-verifying isSessionValid
from middleware.js:265-279) into deps.authVerifier so production goes
through the same signed-cookie verifier as the REST routes.
(2) dashboard-ws.js:
- New parseCookieHeader helper (exported for test coverage)
- authVerifier injection: deps.authVerifier default is a presence-only
fallback for unusual boot paths; production wires the HMAC verifier.
- Upgrade handler replaces substring check with authVerifier(request).
401 includes Connection: close so browsers don't retry. Logs WS upgrade
rejections at WARN with ip + path.
- Removes ?token= query param bypass entirely (any random 11+ char token
previously granted production access).
- 16 KB message size cap defense-in-depth in the message handler.
(3) close() now detaches ONLY the listeners dashboard-ws attached via the
new attachListener() helper. The previous code called
resourceMonitor.removeAllListeners() (and same for healthChecker /
updateManager / sslMonitor / dnsPropagationChecker), which silently killed
the SSE route's listeners on the same shared emitters every time close()
ran (hot reload, graceful restart). The new test proves the SSE listener
survives dashboard-ws.close() and the resourceMonitor still emits to it.
Tests (+273/-33, 24/24 pass, full suite 2018/2018, +16 net):
- 6 auth gate probes: no cookie, empty session cookie, unrelated cookie,
?token= bypass rejected, token+empty-cookie combo rejected, valid
cookie grants 101
- 2 listener-isolation: close() detaches only OUR listeners; close() is
idempotent
- 8 parseCookieHeader unit tests (undefined, empty, single, multi,
whitespace, HMAC-shaped value preservation, malformed pair, empty name)
- Existing DC-076 tests updated to send Cookie header
Refs: codex-as-judge SKILL.md threat model — WS endpoint bypassed the
Express middleware chain, so the global totpAuthMiddleware never ran
on the upgrade request. Auth must be re-asserted at the upgrade handler.
This commit is contained in:
@@ -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<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 = {}) {
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user