Files
dashcaddy/dashcaddy-api/routes/exec.js
DashCaddy Polish Loop 83d7c65bf2 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.
2026-08-18 14:53:57 -07:00

286 lines
9.7 KiB
JavaScript

const { WebSocketServer } = require('ws');
const Docker = require('dockerode');
const url = require('url');
const docker = new Docker();
/**
* DC-072: WebSocket scope authorization — admin-only by default.
*
* Container exec is full root-equivalent access inside the target
* container. Granting it to a key whose scope is `['read']` violates
* least privilege. The validScopes list (`['read','write','admin']`)
* is defined in routes/auth/keys.js; exec requires `admin`.
*
* Defensive: the scope field is coerced via `Array.isArray(...) ? ... : []`
* so a malformed payload (string, object, null, undefined) cannot reach
* `.includes('admin')` and accidentally grant access. Every malformed
* shape falls into the rejection branch with the same 403 envelope.
*
* Tests should call `__test.assertExecScope(auth)` directly rather
* than spinning up a WebSocket server.
*/
function assertExecScope(auth) {
const scope = Array.isArray(auth && auth.scope) ? auth.scope : [];
if (!scope.includes('admin')) {
const err = new Error('Container exec requires admin scope');
err.code = 'DC-072_INSUFFICIENT_SCOPE';
err.statusCode = 403;
err.requiredScope = 'admin';
err.actualScope = scope;
throw err;
}
}
/**
* DC-072: Tighten containerId validation.
*
* Docker container IDs are exactly 64 lowercase hex chars (or 12-char
* short form). The pre-fix regex accepted `_`, `-`, `.`, mixed case,
* and up to 128 chars — Docker would then 404 the inspect call and
* the rejection would surface as a generic 500 in the WS error
* envelope. Pre-validate at the upgrade layer so the rejection is
* fast and the log line discriminates "malformed" from "unknown".
*/
function isValidContainerId(id) {
if (typeof id !== 'string') return false;
// Full 64-char hex, or 12-char short hex
return /^[0-9a-f]{64}$/.test(id) || /^[0-9a-f]{12}$/.test(id);
}
/**
* Attach WebSocket server for container exec/shell
* Route: ws://host/ws/exec/:containerId
* @param {http.Server} server - The HTTP server instance
* @param {Object} log - Logger
*/
module.exports = function attachExecWS(server, log, authManager) {
const wss = new WebSocketServer({ noServer: true });
// Authenticate WebSocket upgrade request before accepting it
server.on('upgrade', async (req, socket, head) => {
const parsed = url.parse(req.url, true);
const match = parsed.pathname.match(/^\/ws\/exec\/([a-zA-Z0-9_.-]+)$/);
if (!match) return; // Not our route — let other handlers deal with it
const containerId = decodeURIComponent(match[1]);
// DC-072: Tighten containerId charset (64-char / 12-char lowercase hex)
if (!isValidContainerId(containerId)) {
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
socket.destroy();
return;
}
// Check auth — require valid JWT or API key from the upgrade request
let auth = null;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const payload = await authManager.verifyJWT(token);
if (payload) {
auth = { type: 'jwt', userId: payload.userId, scope: payload.scope || [] };
}
} else {
const apiKey = req.headers['x-api-key'];
if (apiKey) {
const keyData = await authManager.verifyAPIKey(apiKey);
if (keyData) {
auth = { type: 'apikey', keyId: keyData.keyId, scope: keyData.scopes || [] };
}
}
}
if (!auth) {
log.warn('exec', 'Unauthenticated WebSocket exec attempt', { containerId, ip: req.socket.remoteAddress });
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
// DC-072: Container exec is root-equivalent — require admin scope.
// Pre-fix, a key issued with scope `['read']` (e.g., for monitoring)
// would get a full PTY shell inside any running container. The
// `auth.scope` was captured at lines 39/46 but never checked.
try {
assertExecScope(auth);
} catch (err) {
log.warn('exec', 'Insufficient scope for exec attempt', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
actualScope: err.actualScope,
requiredScope: err.requiredScope,
ip: req.socket.remoteAddress,
});
// 403 with a JSON error envelope over the upgrade socket so the
// dashboard can display "admin required" instead of guessing.
socket.write('HTTP/1.1 403 Forbidden\r\n');
socket.write('Content-Type: application/json\r\n');
socket.write('\r\n');
socket.end(JSON.stringify({
error: err.message,
code: err.code,
requiredScope: err.requiredScope,
actualScope: err.actualScope,
}));
return;
}
// Auth passed — proceed with WebSocket upgrade
wss.handleUpgrade(req, socket, head, (ws) => {
handleExec(ws, containerId, log, auth);
});
});
return wss;
};
async function handleExec(ws, containerId, log, auth) {
let execStream = null;
let execInstance = null;
const sessionStart = Date.now();
try {
const container = docker.getContainer(containerId);
// Verify container exists and is running
const info = await container.inspect();
if (!info.State.Running) {
ws.send(JSON.stringify({ type: 'error', message: 'Container is not running' }));
ws.close();
return;
}
// DC-072: Audit-log the exec session start. Pairs with the end-log
// below so the operator can correlate who opened which shell.
log.info('exec', 'Authenticated exec session started', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
containerName: info.Name,
});
// Detect available shell
let shell = '/bin/sh';
try {
const bashCheck = await container.exec({ Cmd: ['which', 'bash'], AttachStdout: true });
const bashStream = await bashCheck.start();
const chunks = [];
await new Promise((resolve) => {
bashStream.on('data', (chunk) => chunks.push(chunk));
bashStream.on('end', resolve);
});
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
shell = '/bin/bash';
}
} catch (_) {
// Fall back to /bin/sh when bash detection fails
}
execInstance = await container.exec({
Cmd: [shell],
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
});
execStream = await execInstance.start({ hijack: true, stdin: true, Tty: true });
ws.send(JSON.stringify({ type: 'connected', shell, containerId }));
// Docker → WebSocket
execStream.on('data', (chunk) => {
if (ws.readyState === ws.OPEN) {
ws.send(chunk);
}
});
// DC-072: Track whether the end-log has fired so we don't double-log
// when both execStream 'end' and ws 'close' fire (Docker stream end
// closes the WS, which then fires 'close' too — without the flag
// we'd emit the same audit line twice with the same durationMs).
let ended = false;
const logSessionEnd = (reason) => {
if (ended) return;
ended = true;
log.info('exec', 'Exec session ended', {
containerId,
authType: auth.type,
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
durationMs: Date.now() - sessionStart,
reason,
});
};
execStream.on('end', () => {
// DC-072: Audit-log the session end (duration + container) so a
// long-running session is observable in the error log. Normal
// shutdown path: Docker exec stream closes → log + tell client.
logSessionEnd('exec-stream-end');
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'exit' }));
ws.close();
}
});
// WebSocket → Docker
ws.on('message', (data) => {
if (!execStream.writable) return;
try {
// Check for control messages (JSON)
const str = data.toString();
if (str.startsWith('{"type":')) {
const msg = JSON.parse(str);
if (msg.type === 'resize' && execInstance && msg.cols && msg.rows) {
execInstance.resize({ h: msg.rows, w: msg.cols }).catch(() => {});
return;
}
}
} catch (_) {
// Treat message as raw terminal input if JSON parsing fails
}
// Regular terminal input
execStream.write(data);
});
ws.on('close', () => {
// DC-072: Fallback audit-log for abnormal close (browser tab
// closed, network drop, container killed mid-session) where the
// execStream 'end' event never fires. The ended-flag guard makes
// this idempotent with the normal path above.
logSessionEnd('ws-close');
if (execStream) {
try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors on socket close
}
}
});
ws.on('error', (err) => {
log.warn('exec', 'WebSocket error', { containerId, error: err.message });
if (execStream) {
try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors after websocket errors
}
}
});
} catch (err) {
log.error('exec', err, null, { note: 'Failed to start exec session', containerId });
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
ws.close();
}
}
}
// Internal-only export for unit tests. Stripped from the public
// surface; tests import this via the destructure form
// `const { __test } = require('./routes/exec')`.
module.exports.__test = {
assertExecScope,
isValidContainerId,
};