Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b64f23301b | ||
|
|
83d7c65bf2 | ||
|
|
1462024944 | ||
|
|
297332b0e1 | ||
|
|
384f9c8bdb |
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* DC-070: Caddycode config sanitization — validate the structural config
|
||||||
|
* that flows into generateSiteBlock(), and confirm that the post-fix
|
||||||
|
* generation does NOT interpolate raw user input into Caddyfile text.
|
||||||
|
*
|
||||||
|
* The endpoint /caddycode/generate was, pre-fix, the single most exposed
|
||||||
|
* surface in the Caddy-as-code path: every JSON field flowed verbatim into
|
||||||
|
* the Caddyfile text that /caddycode→POST /load feeds to Caddy.
|
||||||
|
*
|
||||||
|
* Bug class under test:
|
||||||
|
* 1. CRLF / newline in `domain` → close the block and inject a new site
|
||||||
|
* 2. `"` (quote) in a header value → break out of the quoted-string
|
||||||
|
* context and append arbitrary directives
|
||||||
|
* 3. `}` in `tls`, `authService`, `stripPrefix`, or `upstream` →
|
||||||
|
* prematurely close the parent block (or open a new one)
|
||||||
|
* 4. `://` or `;` in `upstream` → header injection / path smuggling
|
||||||
|
*
|
||||||
|
* Post-fix: validateGenerationConfig rejects every one of these at the
|
||||||
|
* route layer with 400 + enumerable errors; the helper-level tests here
|
||||||
|
* pin the rejection rules independent of the route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { __test } = require('../../routes/caddycode');
|
||||||
|
const { validateGenerationConfig, escapeCaddyQuotedString, generateSiteBlock } = __test;
|
||||||
|
|
||||||
|
const BASE_OK = {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
};
|
||||||
|
|
||||||
|
function check(cond, msg) {
|
||||||
|
if (!cond) throw new Error('assertion failed: ' + msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-070: caddycode config sanitization', () => {
|
||||||
|
describe('validateGenerationConfig — happy paths', () => {
|
||||||
|
test('minimal valid config passes', () => {
|
||||||
|
const r = validateGenerationConfig(BASE_OK);
|
||||||
|
check(r.valid === true, `expected valid=true, got errors=${JSON.stringify(r.errors)}`);
|
||||||
|
check(Array.isArray(r.errors) && r.errors.length === 0, 'expected no errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full valid config (auth + headers + stripPrefix + tls CA) passes', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
domain: 'chat.example.com',
|
||||||
|
upstream: 'localhost:8096',
|
||||||
|
tls: 'letsencrypt',
|
||||||
|
auth: true,
|
||||||
|
authService: 'chat',
|
||||||
|
upstreamProtocol: 'https',
|
||||||
|
headers: {
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'Strict-Transport-Security': 'max-age=63072000',
|
||||||
|
},
|
||||||
|
stripPrefix: '/api/v1',
|
||||||
|
});
|
||||||
|
check(r.valid === true, `expected valid, got errors=${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('IPv6 bracket-form upstream accepted', () => {
|
||||||
|
const r = validateGenerationConfig({ domain: 'dns.example.com', upstream: '[::1]:5380' });
|
||||||
|
check(r.valid === true, `IPv6 bracket should pass: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bare host without :port rejected (DC-070 round 2)', () => {
|
||||||
|
// Round-1 polish: Caddy reverse_proxy requires an explicit :port
|
||||||
|
// segment. A bare `localhost` would produce a Caddyfile that
|
||||||
|
// either fails to reload or silently picks a default port.
|
||||||
|
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost' });
|
||||||
|
check(r.valid === false, `bare host should reject: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upstream with non-numeric port rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ domain: 'app.example.com', upstream: 'localhost:abc' });
|
||||||
|
check(r.valid === false, `non-numeric port should reject: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateGenerationConfig — injection rejection', () => {
|
||||||
|
test('CRLF in domain rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil.com\nnew.site.example.com {' });
|
||||||
|
check(r.valid === false, 'CRLF should reject');
|
||||||
|
check(r.errors.some((e) => /domain/.test(e)), `expected error to mention domain, got ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('brace in domain rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, domain: 'evil} malicious' });
|
||||||
|
check(r.valid === false, 'brace should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"://" in upstream rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'http://evil.tld/x' });
|
||||||
|
check(r.valid === false, ':// should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('space + brace in upstream rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstream: 'localhost:8080 } evil {' });
|
||||||
|
check(r.valid === false, 'whitespace+brace in upstream should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF in header value rejected', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
...BASE_OK,
|
||||||
|
headers: { 'X-Custom': 'innocent\r\nHost: evil.tld' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'CRLF in header value should reject');
|
||||||
|
check(r.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF error: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad header key charset rejected', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
...BASE_OK,
|
||||||
|
headers: { 'X Bad Key': 'innocent' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'space in header key should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-string tls rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, tls: 'evil directive' });
|
||||||
|
check(r.valid === false, 'whitespace+word tls should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty authService when auth=true rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, auth: true });
|
||||||
|
check(r.valid === false, 'auth=true requires authService');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('upstreamProtocol other than http/https rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, upstreamProtocol: 'javascript' });
|
||||||
|
check(r.valid === false, 'non-http protocol should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripPrefix without leading slash rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: 'app/v1' });
|
||||||
|
check(r.valid === false, 'stripPrefix without leading slash should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripPrefix with brace rejected', () => {
|
||||||
|
const r = validateGenerationConfig({ ...BASE_OK, stripPrefix: '/api/{evil}' });
|
||||||
|
check(r.valid === false, 'stripPrefix with brace should reject');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('multiple errors returned together (enumerable)', () => {
|
||||||
|
const r = validateGenerationConfig({
|
||||||
|
domain: 'evil }',
|
||||||
|
upstream: 'localhost:8080 } malicious {',
|
||||||
|
tls: 'bad tls',
|
||||||
|
auth: true,
|
||||||
|
headers: { 'X B': 'oops' },
|
||||||
|
});
|
||||||
|
check(r.valid === false, 'should reject');
|
||||||
|
check(r.errors.length >= 4, `expected multiple errors, got ${r.errors.length}: ${JSON.stringify(r.errors)}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('escapeCaddyQuotedString', () => {
|
||||||
|
test('escapes backslash and quote', () => {
|
||||||
|
check(escapeCaddyQuotedString('a"b\\c') === 'a\\"b\\\\c', 'should escape both');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('safe string passes through verbatim', () => {
|
||||||
|
check(escapeCaddyQuotedString('hello') === 'hello', 'safe string unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty string survives', () => {
|
||||||
|
check(escapeCaddyQuotedString('') === '', 'empty string survives');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateSiteBlock — quote-breakout defence-in-depth', () => {
|
||||||
|
test('post-validation, header value with " is properly escaped', () => {
|
||||||
|
// The validator REJECTS this upstream (CRLF + quote) but the
|
||||||
|
// generator must also escape `"` even if a future code path bypasses
|
||||||
|
// validation. This test pins the dual-defence.
|
||||||
|
const cfg = {
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Custom': 'a"b' },
|
||||||
|
};
|
||||||
|
// The validator rejects CRLF + chars outside the charset, but a bare
|
||||||
|
// `"` is technically allowed by /[\r\n]/ (only CR/LF). However the
|
||||||
|
// GENERATOR must still escape it. Verify by calling generateSiteBlock
|
||||||
|
// directly with a manually-validated config.
|
||||||
|
const out = generateSiteBlock(cfg);
|
||||||
|
// The header line should appear as: X-Custom "a\"b"
|
||||||
|
// i.e. the raw `"` in the value MUST be escaped, otherwise the Caddyfile
|
||||||
|
// line breaks out of the quoted context.
|
||||||
|
check(out.includes('X-Custom "a\\"b"'), `expected escaped quote, got: ${out}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('route integration — /caddycode/generate wires validation', () => {
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const routes = require('../../routes/caddycode');
|
||||||
|
|
||||||
|
function buildApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
return { app, wrap };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('valid config → 200 + caddyfile', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'app.example.com', upstream: 'localhost:8080' });
|
||||||
|
check(res.status === 200, `expected 200, got ${res.status}`);
|
||||||
|
check(typeof res.body.caddyfile === 'string', 'expected caddyfile string');
|
||||||
|
check(res.body.caddyfile.includes('app.example.com'), 'caddyfile should include domain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('CRLF in domain → 400 + enumerable errors', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'evil.com\nnew block', upstream: 'localhost:8080' });
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
check(res.body.success === false, 'success should be false');
|
||||||
|
check(Array.isArray(res.body.errors), `expected enumerable errors array, got body=${JSON.stringify(res.body)}`);
|
||||||
|
check(res.body.errors.length >= 1, 'at least one error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('"://" in upstream → 400', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({ domain: 'app.example.com', upstream: 'http://evil.tld/x' });
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('header with CRLF → 400 + specific error', async () => {
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Bad': 'oops\r\nHost: evil.tld' },
|
||||||
|
});
|
||||||
|
check(res.status === 400, `expected 400, got ${res.status}`);
|
||||||
|
check(res.body.errors.some((e) => /CR or LF/i.test(e)), `expected CR/LF mention: ${JSON.stringify(res.body.errors)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('end-to-end: header value with quote + backslash round-trips through generator', async () => {
|
||||||
|
// DC-070 round-2 polish (per GLM-5.3 review): the unit tests pin the
|
||||||
|
// escape helper and the route reject path independently, but nothing
|
||||||
|
// asserts the GENERATED Caddyfile is well-formed when a header value
|
||||||
|
// contains BOTH " and \. Verify the generator escapes both so the
|
||||||
|
// resulting line parses as a Caddyfile quoted string.
|
||||||
|
const { app, wrap } = buildApp();
|
||||||
|
app.use('/api/v1', routes({ asyncHandler: wrap }));
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/caddycode/generate')
|
||||||
|
.send({
|
||||||
|
domain: 'app.example.com',
|
||||||
|
upstream: 'localhost:8080',
|
||||||
|
headers: { 'X-Custom': 'a"b\\c' },
|
||||||
|
});
|
||||||
|
check(res.status === 200, `expected 200, got ${res.status}: ${JSON.stringify(res.body)}`);
|
||||||
|
const out = res.body.caddyfile;
|
||||||
|
check(typeof out === 'string', 'expected caddyfile string');
|
||||||
|
// The header line should be EXACTLY: X-Custom "a\"b\\c"
|
||||||
|
// i.e. the raw `"` and `\` in the value MUST be escaped.
|
||||||
|
check(
|
||||||
|
/X-Custom "a\\"b\\\\c"/.test(out),
|
||||||
|
`expected escaped quote+backslash in generated Caddyfile, got: ${out}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,138 @@
|
|||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ok, errorResponse } = require('../src/utils/responses');
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
|
const { REGEX } = require('../src/utilities/constants');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-070: Validate the structural config that flows into generateSiteBlock.
|
||||||
|
*
|
||||||
|
* Threat model: `generateSiteBlock` interpolates user-controlled fields
|
||||||
|
* (domain, tls, authService, headers.*, stripPrefix, upstream) DIRECTLY into
|
||||||
|
* a Caddyfile text block that is later fed to `caddy.modify()` and the
|
||||||
|
* Caddy admin /load endpoint. The /caddycode/generate endpoint is
|
||||||
|
* authenticated (forward_auth gated), but the bug class is "compromised
|
||||||
|
* middleware / pivot" — a JSON-only payload can be smuggled past any
|
||||||
|
* UI-side input checks.
|
||||||
|
*
|
||||||
|
* Pre-fix, every field was trusted: `lines.push(`${domain} {`)` accepted any
|
||||||
|
* string (including newlines that close the block and inject a new site),
|
||||||
|
* `headers[key] = "${value}"` accepted arbitrary quotes (which would break
|
||||||
|
* the surrounding `"..."` Caddy quoted-string context and inject directives),
|
||||||
|
* and `tls`, `authService`, `stripPrefix`, `upstream` had no charset
|
||||||
|
* restrictions at all (spaces, braces, semicolons would land verbatim).
|
||||||
|
*
|
||||||
|
* Post-fix: every field is constrained to a known-safe character class
|
||||||
|
* BEFORE interpolation, and CRLF is rejected outright. Quoted-string
|
||||||
|
* injection in header values is closed by escaping `\` and `"` per the
|
||||||
|
* Caddy quoted-string spec (backslash escapes the next character).
|
||||||
|
*/
|
||||||
|
function validateGenerationConfig(config) {
|
||||||
|
const errors = [];
|
||||||
|
const {
|
||||||
|
domain,
|
||||||
|
upstream,
|
||||||
|
upstreamProtocol = 'http',
|
||||||
|
tls = 'auto',
|
||||||
|
auth = false,
|
||||||
|
authService = null,
|
||||||
|
headers = {},
|
||||||
|
stripPrefix = null,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
// 1. domain — RFC 1123 hostname. Reject anything with whitespace, brace,
|
||||||
|
// semicolon, newline, or non-printable. REGEX.DOMAIN is
|
||||||
|
// /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i in constants.js.
|
||||||
|
if (typeof domain !== 'string' || !REGEX.DOMAIN.test(domain)) {
|
||||||
|
errors.push('domain must be a valid hostname (letters, digits, dots, hyphens)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. upstream — `host:port` form (the only shape Caddy's reverse_proxy
|
||||||
|
// directive takes for non-URL upstreams). Reject `://`, whitespace,
|
||||||
|
// braces. Allow optional IPv6 bracket form `[::1]:5000`. Must
|
||||||
|
// include an explicit :port segment — a bare `localhost` would
|
||||||
|
// produce a Caddyfile that fails to reload (port required for
|
||||||
|
// reverse_proxy upstreams). Two regex branches: (a) bare host with
|
||||||
|
// required :port, (b) bracketed IPv6 literal with required :port.
|
||||||
|
if (typeof upstream !== 'string'
|
||||||
|
|| !/^[a-z0-9.\-]+:\d{1,5}$/i.test(upstream)
|
||||||
|
&& !/^\[[a-z0-9.\-:.]+\]:\d{1,5}$/i.test(upstream)
|
||||||
|
) {
|
||||||
|
errors.push('upstream must be host:port (host letters/digits/dots/hyphens, port 1-65535, optional IPv6 brackets)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. tls — either the literal strings 'auto' / 'internal' (handled
|
||||||
|
// specially below) OR a CA name like 'letsencrypt' / 'internal' that
|
||||||
|
// must match /^[a-z0-9._-]+$/i. Reject whitespace + braces + quotes.
|
||||||
|
if (typeof tls !== 'string' || !/^[a-z0-9._-]+$/i.test(tls)) {
|
||||||
|
errors.push('tls must be one of: auto, internal, or a CA name (letters, digits, dots, underscores, hyphens)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. authService — only meaningful when auth=true; otherwise ignore. Must
|
||||||
|
// match the existing SSO service-id charset (REGEX.SUBDOMAIN).
|
||||||
|
if (auth) {
|
||||||
|
if (typeof authService !== 'string' || !REGEX.SUBDOMAIN.test(authService)) {
|
||||||
|
errors.push('authService must be a valid subdomain (lowercase, alphanumeric, hyphens)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. upstreamProtocol — only 'http' or 'https'. Anything else gets coerced
|
||||||
|
// to 'http' but only after we explicitly accept it; reject obvious
|
||||||
|
// injection vectors here.
|
||||||
|
if (upstreamProtocol !== 'http' && upstreamProtocol !== 'https') {
|
||||||
|
errors.push('upstreamProtocol must be "http" or "https"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. headers — each key must be a valid HTTP header name ([A-Za-z0-9-]+),
|
||||||
|
// each value must be a string with no CR/LF and no unescaped quotes.
|
||||||
|
if (headers && typeof headers === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (typeof key !== 'string' || !/^[A-Za-z0-9-]+$/.test(key)) {
|
||||||
|
errors.push(`header key "${String(key)}" must be HTTP-token chars only ([A-Za-z0-9-])`);
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
errors.push(`header "${key}" value must be a string`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/[\r\n]/.test(value)) {
|
||||||
|
errors.push(`header "${key}" value must not contain CR or LF`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. stripPrefix — must be a leading-slash path with safe chars. Reject
|
||||||
|
// braces, quotes, whitespace, and { } which would let the attacker
|
||||||
|
// open a new Caddyfile block.
|
||||||
|
if (stripPrefix != null) {
|
||||||
|
if (typeof stripPrefix !== 'string' || !/^\/[A-Za-z0-9._\-/]*$/.test(stripPrefix)) {
|
||||||
|
errors.push('stripPrefix must be an absolute path (letters, digits, dots, hyphens, slashes)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape a string for safe interpolation inside a Caddyfile quoted-string
|
||||||
|
* context. Caddy uses the same backslash-escape semantics as JSON-ish
|
||||||
|
* contexts — `\` and `"` MUST be escaped, otherwise the attacker breaks out
|
||||||
|
* of the quoted string and injects arbitrary directives.
|
||||||
|
*
|
||||||
|
* @param {string} s raw header value
|
||||||
|
* @returns {string} escaped value (no embedded newlines; CR/LF were already
|
||||||
|
* rejected by the validator)
|
||||||
|
*/
|
||||||
|
function escapeCaddyQuotedString(s) {
|
||||||
|
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a Caddyfile site block from a structured config.
|
* Generate a Caddyfile site block from a structured config.
|
||||||
* @param {Object} config - Site configuration
|
*
|
||||||
|
* Every interpolated field is now validated by `validateGenerationConfig`
|
||||||
|
* first (see DC-070). Quoted-string values are escaped via
|
||||||
|
* `escapeCaddyQuotedString` so a `"` in a header value cannot break out.
|
||||||
|
*
|
||||||
|
* @param {Object} config - Site configuration (already validated)
|
||||||
* @returns {string} Caddyfile snippet
|
* @returns {string} Caddyfile snippet
|
||||||
*/
|
*/
|
||||||
function generateSiteBlock(config) {
|
function generateSiteBlock(config) {
|
||||||
@@ -38,12 +166,15 @@ function generateSiteBlock(config) {
|
|||||||
const lines = [];
|
const lines = [];
|
||||||
lines.push(`${domain} {`);
|
lines.push(`${domain} {`);
|
||||||
|
|
||||||
// TLS
|
// TLS — only emit a tls directive when explicitly 'internal' or a CA
|
||||||
|
// name; 'auto' means Caddy's default behaviour (no directive needed).
|
||||||
if (tls === 'internal') {
|
if (tls === 'internal') {
|
||||||
lines.push(` tls internal`);
|
lines.push(` tls internal`);
|
||||||
} else if (tls === 'auto') {
|
} else if (tls === 'auto') {
|
||||||
// Default — Caddy auto-provisions Let's Encrypt
|
// Default — Caddy auto-provisions Let's Encrypt
|
||||||
} else if (typeof tls === 'string') {
|
} else {
|
||||||
|
// CA name validated by validateGenerationConfig against
|
||||||
|
// /^[a-z0-9._-]+$/i — safe to interpolate verbatim.
|
||||||
lines.push(` tls ${tls}`);
|
lines.push(` tls ${tls}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +183,8 @@ function generateSiteBlock(config) {
|
|||||||
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
lines.push(` # Redirect HTTP to HTTPS is automatic in Caddy 2`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth gate (DashCaddy forward_auth)
|
// Auth gate (DashCaddy forward_auth) — authService validated by
|
||||||
|
// validateGenerationConfig against REGEX.SUBDOMAIN — safe to interpolate.
|
||||||
if (auth && authService) {
|
if (auth && authService) {
|
||||||
lines.push(` import dashcaddy_auth ${authService}`);
|
lines.push(` import dashcaddy_auth ${authService}`);
|
||||||
}
|
}
|
||||||
@@ -66,16 +198,17 @@ function generateSiteBlock(config) {
|
|||||||
lines.push(` }`);
|
lines.push(` }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom headers
|
// Custom headers — keys validated against /^[A-Za-z0-9-]+$/, values
|
||||||
if (Object.keys(headers).length > 0) {
|
// escaped via escapeCaddyQuotedString before being placed inside "..."
|
||||||
|
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
|
||||||
lines.push(` header {`);
|
lines.push(` header {`);
|
||||||
for (const [key, value] of Object.entries(headers)) {
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
lines.push(` ${key} "${value}"`);
|
lines.push(` ${key} "${escapeCaddyQuotedString(value)}"`);
|
||||||
}
|
}
|
||||||
lines.push(` }`);
|
lines.push(` }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip prefix
|
// Strip prefix — validated to /^\/[A-Za-z0-9._\-/]*$/ — safe.
|
||||||
if (stripPrefix) {
|
if (stripPrefix) {
|
||||||
lines.push(` uri strip_prefix ${stripPrefix}`);
|
lines.push(` uri strip_prefix ${stripPrefix}`);
|
||||||
}
|
}
|
||||||
@@ -118,6 +251,19 @@ module.exports = function({ asyncHandler }) {
|
|||||||
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
return errorResponse(res, 400, 'upstream is required (e.g. localhost:8080)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-070: structural validation BEFORE interpolation. Every field that
|
||||||
|
// flows into the Caddyfile text must satisfy a known-safe charset rule,
|
||||||
|
// and CRLF is rejected outright. Run this BEFORE generateSiteBlock so
|
||||||
|
// the bad input is rejected with a clean 400 + enumerable error list,
|
||||||
|
// not a generated-Caddyfile + 500.
|
||||||
|
const validation = validateGenerationConfig(config);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return errorResponse(res, 400, 'Invalid configuration', {
|
||||||
|
code: 'DC-CCD-700',
|
||||||
|
errors: validation.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const caddyfile = generateSiteBlock(config);
|
const caddyfile = generateSiteBlock(config);
|
||||||
ok(res, { caddyfile, config });
|
ok(res, { caddyfile, config });
|
||||||
@@ -225,3 +371,11 @@ module.exports = function({ asyncHandler }) {
|
|||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DC-070: export helpers for unit-testing the sanitization surface
|
||||||
|
// independently of the route handler.
|
||||||
|
module.exports.__test = {
|
||||||
|
validateGenerationConfig,
|
||||||
|
escapeCaddyQuotedString,
|
||||||
|
generateSiteBlock,
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,6 +4,50 @@ const url = require('url');
|
|||||||
|
|
||||||
const docker = new Docker();
|
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
|
* Attach WebSocket server for container exec/shell
|
||||||
* Route: ws://host/ws/exec/:containerId
|
* Route: ws://host/ws/exec/:containerId
|
||||||
@@ -21,8 +65,8 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
|
|
||||||
const containerId = decodeURIComponent(match[1]);
|
const containerId = decodeURIComponent(match[1]);
|
||||||
|
|
||||||
// Validate container ID format to prevent injection
|
// DC-072: Tighten containerId charset (64-char / 12-char lowercase hex)
|
||||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
|
if (!isValidContainerId(containerId)) {
|
||||||
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
|
log.warn('exec', 'Invalid container ID in WebSocket path', { containerId });
|
||||||
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
@@ -55,6 +99,35 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
return;
|
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
|
// Auth passed — proceed with WebSocket upgrade
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
handleExec(ws, containerId, log, auth);
|
handleExec(ws, containerId, log, auth);
|
||||||
@@ -67,6 +140,7 @@ module.exports = function attachExecWS(server, log, authManager) {
|
|||||||
async function handleExec(ws, containerId, log, auth) {
|
async function handleExec(ws, containerId, log, auth) {
|
||||||
let execStream = null;
|
let execStream = null;
|
||||||
let execInstance = null;
|
let execInstance = null;
|
||||||
|
const sessionStart = Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const container = docker.getContainer(containerId);
|
const container = docker.getContainer(containerId);
|
||||||
@@ -78,10 +152,13 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
return;
|
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', {
|
log.info('exec', 'Authenticated exec session started', {
|
||||||
containerId,
|
containerId,
|
||||||
authType: auth.type,
|
authType: auth.type,
|
||||||
authId: auth.type === 'jwt' ? auth.userId : auth.keyId
|
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
|
||||||
|
containerName: info.Name,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect available shell
|
// Detect available shell
|
||||||
@@ -120,7 +197,28 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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', () => {
|
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) {
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'exit' }));
|
ws.send(JSON.stringify({ type: 'exit' }));
|
||||||
ws.close();
|
ws.close();
|
||||||
@@ -148,6 +246,11 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
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) {
|
if (execStream) {
|
||||||
try { execStream.destroy(); } catch (_) {
|
try { execStream.destroy(); } catch (_) {
|
||||||
// Ignore stream teardown errors on socket close
|
// Ignore stream teardown errors on socket close
|
||||||
@@ -172,3 +275,11 @@ async function handleExec(ws, containerId, log, auth) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user