Pre-fix, dashcaddy-api/routes/exec.js (the ws://host/ws/exec/:containerId
WebSocket container terminal endpoint) captured auth.scope at lines 39/46
but never enforced it — any API key or JWT, regardless of scope, got a
full PTY-backed shell inside the running container. A key issued with
scope ['read'] (a legitimate monitoring/observability scope) could
escalate to a root-equivalent shell. Container exec is full root inside
the container's user namespace, so this was a privilege-escalation across
the auth trust boundary.
Fix:
1. assertExecScope(auth) requires scope.includes('admin'); throws a
tagged 403 error (DC-072_INSUFFICIENT_SCOPE) on rejection with
requiredScope + actualScope in the envelope.
2. Called BEFORE wss.handleUpgrade so the WS gate cannot be bypassed.
3. 403 over the upgrade socket is JSON (code, requiredScope, actualScope)
so the dashboard can show operator-actionable messages.
4. isValidContainerId(id) tightened to Docker's actual charset
(12 or 64 lowercase hex). Pre-fix regex accepted _, -, ., mixed
case, and any length up to 128; Docker would 404 the inspect and the
rejection surfaced as a generic 500.
5. Audit-log pair: session start (container name + auth id) and session
end with durationMs + reason ('exec-stream-end' vs 'ws-close'
for abnormal disconnects); idempotent via ended-flag guard.
6. Both helpers exported via __test for unit tests (no live WS).
Tests: 20 new tests in __tests__/routes/exec.routes.test.js cover:
- assertExecScope: admin passes; read/write/empty/undefined/null/non-array
rejected with the canonical 403 envelope.
- isValidContainerId: 12/64 lowercase hex accepted; uppercase / mixed /
non-hex / _.- / wrong length / null / non-string / padded / CRLF
payload rejected.
Full suite: 2327/2327 tests passing across 100 suites (zero regressions).
GLM-5.3 round 1: A with 2 LOW polish (scope-coercion defensive comment +
abnormal-close audit-log fallback). Both folded into the same commit.
Round 2: A. Ship.
Routes/caddy-upstreams.js had 4 callsites with argument-order swapped: errorResponse(res, 'message', 503) instead of errorResponse(res, 503, 'message'). The canonical signature from src/utils/responses.js:66 takes statusCode FIRST; the swapped call passed a STRING where Express expected a status code. res.status('Caddy upstream watcher not initialized') throws RangeError [ERR_HTTP_INVALID_STATUS_CODE], Express's error middleware catches it, and the response is 500 with an HTML stack trace instead of the intended 503 JSON. Four `!caddyUpstreamWatcher` defensive guards had this exact pattern; all fixed.
Defense-in-depth (responses.js): errorResponse() now validates that statusCode is an integer in 100..599 and that message is a string BEFORE calling res.status(). Future arg-order mistakes fail fast with a clear TypeError naming the wrong arg and the message — instead of writing a 500 HTML panic to the wire. Legacy error(res, message, statusCode) helper (used by ~7 files that import as 'error: errorResponse' alias) is intentionally untouched.
Tests (__tests__/utils-responses-dc-062.test.js, NEW, 21 tests pass):
- correct (res, 503, msg) order: 503 JSON
- swapped (res, msg, statusCode) order: TypeError (was: silent 500 HTML panic)
- 10 invalid-statusCode cases: NaN, Infinity, '503', null, undefined, underflow, overflow, float, object, array — all rejected
- non-string message rejected
- DC-086 extras.code propagation preserved
- legacy error() helper regression: still works
- pre-fix Express server proves the bug class (500 HTML when statusCode is a string)
- all 4 caddy-upstreams routes with null watcher now return 503 JSON
- static source scan: 0 swapped patterns, 4 canonical (statusCode, 'message') occurrences
Full suite: 92 suites / 2039 tests / all green pre and post fix.
[glm-grade=A] from deleg_45e44614 (3 tool calls, 82s, MiniMax-M3 stand-in per Sami's 2026-08-17 authorization)