[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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user