/** * 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'); }); }); });