fix(exec): scope-based authorization + tighten containerId charset (DC-072) [glm-grade=A]

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.
This commit is contained in:
DashCaddy Polish Loop
2026-08-18 14:53:57 -07:00
parent 1462024944
commit 83d7c65bf2
2 changed files with 306 additions and 3 deletions
@@ -0,0 +1,192 @@
/**
* DC-072: WebSocket exec scope-based authorization + containerId charset
* hardening.
*
* Bug class under test:
* 1. Pre-fix `routes/exec.js` captured `auth.scope` (line 39/46) but
* NEVER enforced it. A JWT or API key whose scope was `['read']`
* (a legitimate monitoring/observability scope) would be granted a
* full PTY-backed shell inside any running container. Container
* exec is root-equivalent inside the container's user namespace,
* so this is a privilege escalation: a read-only key holder could
* run arbitrary commands, exfiltrate mounted volumes, or pivot
* to the host network.
*
* 2. Pre-fix `containerId` regex `/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/`
* accepted mixed case, `_`, `-`, `.`, and any length up to 128.
* Docker container IDs are exactly 64 lowercase hex (or 12-char
* short form). The pre-fix validator would pass any string that
* looked vaguely ID-shaped; Docker's inspect() would then 404.
*
* Post-fix: `assertExecScope(auth)` requires `admin` scope and throws a
* 403-tagged error. `isValidContainerId(id)` accepts only 12 or 64
* lowercase hex chars. Both helpers are exported via `__test`.
*/
const { __test } = require('../../routes/exec');
const { assertExecScope, isValidContainerId } = __test;
function check(cond, msg) {
if (!cond) throw new Error('assertion failed: ' + msg);
}
describe('DC-072: exec WebSocket scope-based authorization', () => {
describe('assertExecScope — admin required', () => {
test('admin scope passes', () => {
// Should not throw
assertExecScope({ type: 'jwt', scope: ['admin'] });
assertExecScope({ type: 'apikey', scope: ['admin', 'read'] });
});
test('read-only scope rejected with DC-072_INSUFFICIENT_SCOPE', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: ['read'] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on read-only scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
check(caught.requiredScope === 'admin', `expected requiredScope=admin, got ${caught.requiredScope}`);
check(Array.isArray(caught.actualScope) && caught.actualScope[0] === 'read', `expected actualScope=['read'], got ${JSON.stringify(caught.actualScope)}`);
});
test('write-only scope rejected (write ≠ admin)', () => {
let caught = null;
try {
assertExecScope({ type: 'jwt', scope: ['write'] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on write-only scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', `expected code DC-072_INSUFFICIENT_SCOPE, got ${caught.code}`);
check(caught.statusCode === 403, `expected statusCode 403, got ${caught.statusCode}`);
});
test('empty scope rejected', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: [] });
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on empty scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('undefined scope rejected (null-safety)', () => {
let caught = null;
try {
assertExecScope({ type: 'jwt' }); // no scope field
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on undefined scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('null auth rejected', () => {
let caught = null;
try {
assertExecScope(null);
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on null auth');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('non-array scope rejected (defensive)', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', scope: 'admin' }); // string, not array
} catch (e) {
caught = e;
}
check(caught !== null, 'expected assertExecScope to throw on non-array scope');
check(caught.code === 'DC-072_INSUFFICIENT_SCOPE', 'expected DC-072_INSUFFICIENT_SCOPE code');
});
test('error envelope carries operator-actionable fields', () => {
let caught = null;
try {
assertExecScope({ type: 'apikey', keyId: 'k_test', scope: ['read'] });
} catch (e) {
caught = e;
}
check(caught.message === 'Container exec requires admin scope', `expected canonical message, got ${caught.message}`);
check(typeof caught.requiredScope === 'string' && caught.requiredScope === 'admin', 'requiredScope present');
check(Array.isArray(caught.actualScope), 'actualScope is array');
});
});
describe('isValidContainerId — Docker charset (12 or 64 lowercase hex)', () => {
test('64-char lowercase hex accepted (full Docker ID)', () => {
// Real-world example: dashcaddy-api container ID
check(isValidContainerId('abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === true, '64-char hex should pass');
});
test('12-char lowercase hex accepted (short form)', () => {
check(isValidContainerId('abcdef012345') === true, '12-char hex should pass');
});
test('uppercase hex rejected (Docker IDs are lowercase)', () => {
check(isValidContainerId('ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789') === false, 'uppercase 64-char should fail');
check(isValidContainerId('ABCDEF012345') === false, 'uppercase 12-char should fail');
});
test('mixed case rejected', () => {
check(isValidContainerId('Abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789') === false, 'mixed case 64-char should fail');
});
test('non-hex chars rejected', () => {
check(isValidContainerId('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz') === false, 'g-z hex should fail');
check(isValidContainerId('abc!@#$%^&*()_+-=[]{}|\\:;\'",.<>/?0123456789012345678901234567890123') === false, 'special chars should fail');
});
test('underscore / dot / dash rejected (pre-fix allowed these)', () => {
// Pre-fix regex accepted `_`, `-`, `.` — all are non-Docker
check(isValidContainerId('my_container_1') === false, 'underscore should fail');
check(isValidContainerId('my.container.1') === false, 'dot should fail');
check(isValidContainerId('my-container-1') === false, 'dash should fail');
});
test('wrong length rejected', () => {
check(isValidContainerId('abcdef0123456') === false, '13-char should fail'); // 12 + 1
check(isValidContainerId('abcdef01234567') === false, '14-char should fail'); // 12 + 2
check(isValidContainerId('abcdef0123456789a') === false, '65-char should fail'); // 64 + 1
});
test('empty string rejected', () => {
check(isValidContainerId('') === false, 'empty string should fail');
});
test('null / undefined / non-string rejected (defensive)', () => {
check(isValidContainerId(null) === false, 'null should fail');
check(isValidContainerId(undefined) === false, 'undefined should fail');
check(isValidContainerId(12345) === false, 'number should fail');
check(isValidContainerId({}) === false, 'object should fail');
check(isValidContainerId([]) === false, 'array should fail');
});
test('whitespace / padding rejected', () => {
check(isValidContainerId(' abcdef012345 ') === false, 'padded should fail');
check(isValidContainerId('\nabcdef012345\n') === false, 'CRLF-padded should fail');
});
test('CRLF injection rejected (defensive against pre-fix attack class)', () => {
// Pre-fix regex accepted 128 chars with dots; a payload like
// `aa.bb.cc.dd\r\nSet-Cookie:...` would have passed. Post-fix
// the LF + non-hex + wrong-length combo fails on every axis.
check(isValidContainerId('aa\r\nbb') === false, 'CRLF payload should fail');
});
});
describe('__test exports shape', () => {
test('exports assertExecScope and isValidContainerId', () => {
check(typeof __test.assertExecScope === 'function', 'assertExecScope is a function');
check(typeof __test.isValidContainerId === 'function', 'isValidContainerId is a function');
});
});
});