Compare commits
19
Commits
dc/DC-059
...
7db152499c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7db152499c | ||
|
|
a9bb4a1835 | ||
|
|
b64f23301b | ||
|
|
83d7c65bf2 | ||
|
|
1462024944 | ||
|
|
297332b0e1 | ||
|
|
384f9c8bdb | ||
|
|
933606ce3f | ||
|
|
5382d832d9 | ||
|
|
c6b2f556c2 | ||
|
|
4e75b13e90 | ||
|
|
597bbf67c8 | ||
|
|
a2e2a12eb8 | ||
|
|
c01a011d47 | ||
|
|
74fe35d969 | ||
|
|
678a0160c4 | ||
|
|
9779feae70 | ||
|
|
30d5fdbb2c | ||
|
|
71d20ceef3 |
@@ -336,32 +336,77 @@ describe('AutoRestartManager', () => {
|
||||
});
|
||||
|
||||
describe('_resolveContainerId', () => {
|
||||
test('returns containerId from status.details when present', () => {
|
||||
test('returns containerId from status.details when present', async () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||
const cid = await manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||
expect(cid).toBe('cid-details');
|
||||
});
|
||||
|
||||
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
||||
test('falls back to healthChecker.config.services[serviceId].containerId', async () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-hc');
|
||||
});
|
||||
|
||||
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
||||
test('DC-060: awaits async servicesStateManager.read() and resolves containerId', async () => {
|
||||
// Regression test for the auto-restart silently no-op bug:
|
||||
// _resolveContainerId used to fire servicesStateManager.read() via
|
||||
// .then(...) and discard the result. Callers gated on the return
|
||||
// value, so a healthy→unhealthy transition whose only containerId
|
||||
// source was the async state manager never triggered handleContainerDown.
|
||||
const { manager, servicesStateManager } = makeManager();
|
||||
servicesStateManager.read.mockReturnValue([
|
||||
servicesStateManager.read.mockResolvedValue([
|
||||
{ id: 'svc-1', containerId: 'cid-state' },
|
||||
]);
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-state');
|
||||
});
|
||||
|
||||
test('returns null when no source has a containerId', () => {
|
||||
test('returns null when no source has a containerId', async () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
||||
const cid = await manager._resolveContainerId('svc-unknown', { details: {} });
|
||||
expect(cid).toBeNull();
|
||||
});
|
||||
|
||||
test('swallows servicesStateManager.read() rejection', async () => {
|
||||
const { manager, servicesStateManager } = makeManager();
|
||||
servicesStateManager.read.mockRejectedValue(new Error('disk gone'));
|
||||
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-060: healthy→unhealthy transitions trigger restart via async lookup', () => {
|
||||
test('handleContainerDown is invoked with containerId from async state-manager lookup', async () => {
|
||||
// End-to-end: containerId comes ONLY from servicesStateManager.read()
|
||||
// (the production path for services.json-backed deployments).
|
||||
const { manager, docker, servicesStateManager } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
manager._previousHealth.set('svc-1', 'up');
|
||||
servicesStateManager.read.mockResolvedValue([
|
||||
{ id: 'svc-1', containerId: 'cid-from-state' },
|
||||
]);
|
||||
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
||||
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-from-state');
|
||||
});
|
||||
|
||||
test('handleContainerDown is NOT invoked when async lookup returns no containerId', async () => {
|
||||
const { manager, servicesStateManager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
manager._previousHealth.set('svc-1', 'up');
|
||||
servicesStateManager.read.mockResolvedValue([
|
||||
{ id: 'svc-1' /* no containerId */ },
|
||||
]);
|
||||
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* DC-073: regression tests for the caddy-upstreams mute endpoints.
|
||||
*
|
||||
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
||||
* rejected unknown hosts with a 400 "not a known upstream". The
|
||||
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
||||
* check entirely and would silently call `setMuted(phantom, true)`,
|
||||
* persisting a phantom entry into the watcher's muted Set (which is
|
||||
* disk-persisted via `_saveState()`).
|
||||
*
|
||||
* These tests prove:
|
||||
* (1) every endpoint now rejects an unknown host with 400
|
||||
* (2) the rejection happens BEFORE setMuted is invoked (no state
|
||||
* corruption — `fakeWatcher.setMuted` is asserted to be
|
||||
* untouched on the rejection path)
|
||||
* (3) the rejection message is the canonical "not a known upstream"
|
||||
* so callers can branch on it
|
||||
* (4) known hosts still mute / unmute correctly (no regression)
|
||||
* (5) the bare handler still accepts the body { host, muted: 'false' }
|
||||
* string-coercion quirk it had before (so the original
|
||||
* caddy-upstreams.routes.test.js suite keeps passing)
|
||||
*
|
||||
* @module __tests__/routes/caddy-upstreams-dc073
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;
|
||||
|
||||
function buildRouter(deps) {
|
||||
const mod = require('../../routes/caddy-upstreams');
|
||||
return mod(deps);
|
||||
}
|
||||
|
||||
function buildApp(mod_deps) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
app.use(buildRouter({
|
||||
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
...mod_deps,
|
||||
}));
|
||||
// Error middleware MUST be registered AFTER routes so it actually catches.
|
||||
app.use((err, req, res, next) => {
|
||||
if (err && err.statusCode === 400) {
|
||||
return res.status(400).json({ success: false, error: err.message });
|
||||
}
|
||||
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
|
||||
const upstreams = new Map(known.map(h => [h, { host: h }]));
|
||||
return {
|
||||
upstreams,
|
||||
setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
|
||||
snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
|
||||
};
|
||||
}
|
||||
|
||||
describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
|
||||
describe('validateAndMuteHost helper (unit)', () => {
|
||||
test('rejects empty / non-string host', () => {
|
||||
const w = makeKnownWatcher();
|
||||
expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
|
||||
expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
|
||||
expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
|
||||
expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects host longer than 253 chars', () => {
|
||||
const w = makeKnownWatcher();
|
||||
const long = 'a'.repeat(254);
|
||||
expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects host with charset-violating chars', () => {
|
||||
const w = makeKnownWatcher();
|
||||
for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
|
||||
expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
|
||||
}
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
// This is the regression: pre-fix, this call would have
|
||||
// silently added 'phantom.test:12345' to watcher.muted.
|
||||
expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
|
||||
.toThrow(/not a known upstream/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
const result = validateAndMuteHost(w, 'known:80', true);
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
|
||||
expect(result).toEqual({ host: 'known:80', muted: true });
|
||||
|
||||
w.setMuted.mockClear();
|
||||
const result2 = validateAndMuteHost(w, 'known:80', false);
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||
expect(result2).toEqual({ host: 'known:80', muted: false });
|
||||
});
|
||||
|
||||
test('handles missing watcher / upstreams map (defensive)', () => {
|
||||
expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
|
||||
expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
|
||||
expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /caddy/upstreams/mute (bare body-style)', () => {
|
||||
test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'phantom:12345' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.error).toMatch(/not a known upstream/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ host: 'known:80', muted: 'false' }),
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(body.success).toBe(true);
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
|
||||
test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
// Pre-fix this would have silently added 'phantom.test:12345' to
|
||||
// the watcher's muted Set and called _saveState(). Post-fix it
|
||||
// returns 400 and never touches the watcher.
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.error).toMatch(/not a known upstream/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mutes a known host via bare POST (no body)', async () => {
|
||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.success).toBe(true);
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
||||
});
|
||||
|
||||
test('mutes via ?muted=true query', async () => {
|
||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
test('unmutes via body { muted: false }', async () => {
|
||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ muted: false }),
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
|
||||
test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
|
||||
const w = makeKnownWatcher(['known:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(400);
|
||||
expect(body.error).toMatch(/not a known upstream/);
|
||||
expect(w.setMuted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('unmutes a known host', async () => {
|
||||
const w = makeKnownWatcher(['known.svc.example:80']);
|
||||
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
||||
const server = app.listen(0);
|
||||
const { port } = server.address();
|
||||
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const body = await res.json();
|
||||
server.close();
|
||||
expect(res.status).toBe(200);
|
||||
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('router introspection (DC-057-style mount-count assertion)', () => {
|
||||
test('exactly one POST handler per (method,path) — no duplicate registration', () => {
|
||||
const w = makeKnownWatcher();
|
||||
const router = buildRouter({
|
||||
asyncHandler: (fn) => fn,
|
||||
caddyUpstreamWatcher: w,
|
||||
healthChecker: { incidents: [] },
|
||||
});
|
||||
const sigs = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
// Each (method,path) should appear exactly once
|
||||
const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
|
||||
for (const [sig, n] of Object.entries(counts)) {
|
||||
expect({ sig, n }).toEqual({ sig, n: 1 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ function createFleetApp(log) {
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/fleet');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', routes({ log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||
app.use('/api/v1', routes({ log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -96,40 +96,43 @@ describe('DC-108: Fleet Management', () => {
|
||||
});
|
||||
|
||||
it('POST /hosts registers a new host', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Test Host', hostname: '192.168.1.100', apiKey: 'dk_test_12345', tags: ['prod'] });
|
||||
// DC-068: SSRF hardening rejects private-range IPv4 literals by default.
|
||||
// Use a public host literal to exercise the registration happy path.
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Test Host', hostname: '8.8.8.8', port: 3001, apiKey: 'dk_test_12345', tags: ['prod'] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.name).toBe('Test Host');
|
||||
expect(res.body.host.apiKey).toBe('***'); // Key is masked
|
||||
expect(res.body.host.apiKeyHash).toBeTruthy();
|
||||
expect(res.body.host.id).toBeTruthy();
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.name).toBe('Test Host');
|
||||
expect(res.body.host.apiKey).toBe('***'); // Key is masked
|
||||
expect(res.body.host.apiKeyHash).toBeTruthy();
|
||||
expect(res.body.host.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('POST /hosts returns 400 without name', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ hostname: '8.8.8.8', port: 3001 });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /deploy generates deployment plan', async () => {
|
||||
const app = createFleetApp();
|
||||
// First register a host (DC-068: use a public IPv4 since private IPs
|
||||
// are rejected by default).
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Host 1', hostname: '8.8.8.8', port: 3001 });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex', config: { port: 32400 } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.plan[0].templateId).toBe('plex');
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /hosts returns 400 without name', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ hostname: '192.168.1.100' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('POST /deploy generates deployment plan', async () => {
|
||||
const app = createFleetApp();
|
||||
// First register a host
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Host 1', hostname: '10.0.0.1' });
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex', config: { port: 32400 } });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.totalHosts).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.plan[0].templateId).toBe('plex');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* DC-103 / DC-064: discover-adopt regression suite
|
||||
*
|
||||
* DC-064 fixes the Caddy admin URL hardcode (`http://localhost:2019` → resolved
|
||||
* from the injected caddy context's `adminUrl`) and stops the route from
|
||||
* reaching raw `fetch` — it must use the injected `fetchT` (which carries
|
||||
* Origin + httpAgent plumbing via src/utils/http.js) so non-loopback Caddy
|
||||
* admin binds (enforce_origin=true) don't 403 the request.
|
||||
*
|
||||
* This suite pins all four invariants:
|
||||
* 1. Route module signature accepts `fetchT` (won't throw if ctx doesn't pass it).
|
||||
* 2. The mounted route uses `fetchT` when provided (proves by mock counts).
|
||||
* 3. The Caddy admin URL is resolved from `caddy.adminUrl`, NOT hardcoded.
|
||||
* 4. Validation: 400 on missing inputs, 400 on bad subdomain, 409 on duplicate id.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
function createApp({ servicesStateManager, caddy, fetchT, adminUrl } = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const discoverAdoptRoutes = require('../../routes/discover-adopt');
|
||||
|
||||
app.use('/api/v1', discoverAdoptRoutes({
|
||||
docker: null,
|
||||
servicesStateManager: servicesStateManager || null,
|
||||
caddy: caddy === undefined
|
||||
? { adminUrl: adminUrl || 'http://localhost:2019' }
|
||||
: caddy,
|
||||
dns: null,
|
||||
siteConfig: { tld: '.sami' },
|
||||
fetchT,
|
||||
asyncHandler,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
// Helper state manager so the route always has somewhere to write
|
||||
function makeStateManager(initial = []) {
|
||||
let services = Array.isArray(initial) ? [...initial] : [];
|
||||
return {
|
||||
_services: services,
|
||||
// eslint-disable-next-line require-await
|
||||
read: jest.fn().mockImplementation(async () => services),
|
||||
// eslint-disable-next-line require-await
|
||||
update: jest.fn().mockImplementation(async (mutator) => {
|
||||
const next = mutator(services);
|
||||
services = next;
|
||||
return services;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('DC-064: discover-adopt Caddy admin API safety', () => {
|
||||
describe('DI: fetchT is forwarded to the Caddy admin call', () => {
|
||||
it('uses injected fetchT (not raw fetch) when generating Caddy route', async () => {
|
||||
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
|
||||
try {
|
||||
const sm = makeStateManager();
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://caddy-admin.local:2019' },
|
||||
fetchT: fetchTMock,
|
||||
});
|
||||
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc123def456',
|
||||
serviceId: 'myapp',
|
||||
name: 'My App',
|
||||
port: 8080,
|
||||
protocol: 'http',
|
||||
generateDns: false,
|
||||
generateRoute: true,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(fetchTMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchTMock.mock.calls[0][0]).toBe('http://caddy-admin.local:2019/config/apps/http/servers/srv0/routes');
|
||||
expect(fetchTMock.mock.calls[0][1]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
|
||||
});
|
||||
// Raw fetch must NOT have been called
|
||||
expect(rawFetchSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rawFetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT hardcode http://localhost:2019 when caddy.adminUrl is provided', async () => {
|
||||
const fetchTMock = jest.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
const sm = makeStateManager();
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://caddy-admin.production:2019' },
|
||||
fetchT: fetchTMock,
|
||||
});
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const calledUrl = fetchTMock.mock.calls[0][0];
|
||||
expect(calledUrl.startsWith('http://caddy-admin.production:2019')).toBe(true);
|
||||
expect(calledUrl.includes('localhost:2019')).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to raw fetch when fetchT is omitted (test-only path)', async () => {
|
||||
const rawFetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, status: 200 });
|
||||
try {
|
||||
const sm = makeStateManager();
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://localhost:2019' },
|
||||
fetchT: null, // explicitly omitted
|
||||
});
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc123def456', serviceId: 'myapp', name: 'My App', port: 8080,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
// Raw fetch used because fetchT is null
|
||||
expect(rawFetchSpy).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
rawFetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('source convention: static scan', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
it('does not contain the hardcoded Caddy admin URL string', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
||||
'utf8'
|
||||
);
|
||||
// The exact hardcode from before must be gone
|
||||
const hardcodeMatches = (src.match(/const\s+caddyAdminUrl\s*=\s*['"]http:\/\/localhost:2019['"]/g) || []).length;
|
||||
expect(hardcodeMatches).toBe(0);
|
||||
});
|
||||
|
||||
it('does not call raw fetch() — must use httpClient (fetchT or passed fetch)', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
||||
'utf8'
|
||||
);
|
||||
// Raw `fetch(` for the Caddy admin call would be a regression
|
||||
const rawFetchMatches = (src.match(/await\s+fetch\(/g) || []).length;
|
||||
expect(rawFetchMatches).toBe(0);
|
||||
});
|
||||
|
||||
it('declares fetchT in the destructure', () => {
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '../../routes/discover-adopt.js'),
|
||||
'utf8'
|
||||
);
|
||||
expect(src).toMatch(/function\s*\(\s*\{[^}]*fetchT[^}]*\}\s*\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation unchanged', () => {
|
||||
it('returns 400 when containerId/serviceId/name are missing', async () => {
|
||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: '', name: '',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on invalid port', async () => {
|
||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 99999,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 on invalid subdomain (must be lowercase, alphanumeric, hyphens)', async () => {
|
||||
const app = createApp({ servicesStateManager: makeStateManager() });
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: 'Bad_SubDomain!', name: 'My App', port: 80,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 409 on duplicate service id', async () => {
|
||||
const sm = makeStateManager([{ id: 'myapp', name: 'Existing' }]);
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://localhost:2019' },
|
||||
fetchT: () => Promise.resolve({ ok: true, status: 200 }),
|
||||
});
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: 'myapp', name: 'Dup', port: 80,
|
||||
});
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Caddy route failure does not corrupt the service entry', () => {
|
||||
it('still returns 200/201 result for service when generateRoute=false', async () => {
|
||||
const sm = makeStateManager();
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://localhost:2019' },
|
||||
fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200 }),
|
||||
});
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
|
||||
generateRoute: false,
|
||||
generateDns: false,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.service).toBeTruthy();
|
||||
expect(res.body.service.id).toBe('myapp');
|
||||
expect(sm.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('captures Caddy API failure in caddyRoute field without rolling back the service', async () => {
|
||||
const sm = makeStateManager();
|
||||
const app = createApp({
|
||||
servicesStateManager: sm,
|
||||
caddy: { adminUrl: 'http://localhost:2019' },
|
||||
fetchT: jest.fn().mockResolvedValue({ ok: false, status: 403 }),
|
||||
});
|
||||
const res = await request(app).post('/api/v1/discover/adopt').send({
|
||||
containerId: 'abc', serviceId: 'myapp', name: 'My App', port: 80,
|
||||
generateDns: false,
|
||||
generateRoute: true,
|
||||
});
|
||||
// Service was still written even though route generation failed
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.service).toBeTruthy();
|
||||
expect(res.body.caddyRoute.status).toBe('failed');
|
||||
expect(res.body.caddyRoute.error).toMatch(/403/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* DC-063: errorResponse arg-order invariant regression suite.
|
||||
*
|
||||
* Three layers of correctness pinned by this test:
|
||||
*
|
||||
* (1) The validator at responses.js:76-98 catches wrong-order callers
|
||||
* with a clear TypeError naming statusCode. Defense-in-depth: any
|
||||
* future swap is caught at the smallest possible blast radius
|
||||
* (one TypeError on the request thread) instead of an HTTP 500 HTML
|
||||
* panic for the operator and client.
|
||||
*
|
||||
* (2) The static trees under dashcaddy-api/routes/ and
|
||||
* dashcaddy-api/src/utilities/ follow ONE of two equivalent
|
||||
* conventions consistently:
|
||||
*
|
||||
* Convention A — canonical import `errorResponse` from responses.js.
|
||||
* Callsite shape: errorResponse(res, statusCode, message, extras?)
|
||||
* statusCode must be an integer 100..599; message must be a string.
|
||||
*
|
||||
* Convention B — alias import `error: errorResponse` from responses.js,
|
||||
* which binds the local `errorResponse` to the message-first
|
||||
* helper `error(res, message, statusCode = 500)`.
|
||||
* Callsite shape: errorResponse(res, message, statusCode)
|
||||
*
|
||||
* Mixing the alias-import with the canonical-shape callsite is the
|
||||
* DC-063 bug class: at runtime, the alias function fires
|
||||
* `res.status('event not found')` → TypeError → HTTP 500 HTML panic,
|
||||
* silently masking the intended 4xx JSON response for the client.
|
||||
* The validator at (1) does NOT help because the alias path skips it.
|
||||
*
|
||||
* (3) End-to-end smoke for one of each fixed-file: live HTTP hits the
|
||||
* endpoint with the malformed input that triggers the fix-callsite
|
||||
* branch, and asserts the wire response is the expected 4xx JSON
|
||||
* (status + content-type + body) — never a 500 HTML panic.
|
||||
*
|
||||
* Origin (DC-062): shipped 2026-08-18 by Hermes loop. Found 4 callsites in
|
||||
* routes/caddy-upstreams.js and added the validator.
|
||||
*
|
||||
* DC-063 (this file): extended the search across the routes tree with
|
||||
* alias-import awareness. Found 18 instances of the alias-imported +
|
||||
* canonical-shape callsite bug class in 2 files (security.js + 3 calls
|
||||
* in services.js). Fixed by switching those imports to canonical and
|
||||
* rewriting the remaining 4 alias-shape callsites in services.js to
|
||||
* canonical-shape. Adding this regression test to prevent the same
|
||||
* swap from being reintroduced in future route file edits.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const glob = require('glob');
|
||||
|
||||
const repoRoot = path.join(__dirname, '..', '..'); // dashcaddy-api/
|
||||
const { errorResponse, error: aliasError } = require(
|
||||
path.join(repoRoot, 'src/utils/responses')
|
||||
);
|
||||
|
||||
// ─── (1) Type validator (defense-in-depth) ────────────────────────────────
|
||||
describe('DC-063: errorResponse type validator (defense-in-depth)', () => {
|
||||
function makeRes() {
|
||||
return { status: () => makeRes(), json: () => makeRes() };
|
||||
}
|
||||
|
||||
test('canonical (res, statusCode, message) does not throw and JSON is well-formed', () => {
|
||||
expect(() => errorResponse(makeRes(), 400, 'Invalid level')).not.toThrow();
|
||||
expect(() => errorResponse(makeRes(), 503, 'downstream unavailable', { code: 'DC-503' }))
|
||||
.not.toThrow();
|
||||
});
|
||||
|
||||
test('swapped canonical-shape throws TypeError naming statusCode', () => {
|
||||
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
||||
.toThrow(TypeError);
|
||||
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
||||
.toThrow(/statusCode must be an integer HTTP status \(100\.\.599\)/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[0, 'below range'],
|
||||
[99, 'below range'],
|
||||
[600, 'above range'],
|
||||
[3.14, 'non-integer'],
|
||||
[NaN, 'NaN'],
|
||||
[Infinity, 'Infinity'],
|
||||
])('rejects numeric out-of-band statusCode %p (%s)', (bad) => {
|
||||
expect(() => errorResponse(makeRes(), bad, 'msg')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('rejects non-string message', () => {
|
||||
expect(() => errorResponse(makeRes(), 400, 42)).toThrow(TypeError);
|
||||
expect(() => errorResponse(makeRes(), 400, null)).toThrow(TypeError);
|
||||
expect(() => errorResponse(makeRes(), 400, { err: 'oops' })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('extras object merges into body and surfaces top-level code (DC-086)', () => {
|
||||
const captured = {};
|
||||
const res = {
|
||||
status(c) { captured.status = c; return res; },
|
||||
json(b) { captured.body = b; return res; },
|
||||
};
|
||||
errorResponse(res, 400, 'Invalid input', { code: 'DC-400', field: 'level' });
|
||||
expect(captured.status).toBe(400);
|
||||
expect(captured.body).toEqual({
|
||||
success: false,
|
||||
error: 'Invalid input',
|
||||
field: 'level',
|
||||
code: 'DC-400',
|
||||
});
|
||||
});
|
||||
|
||||
test('alias error(res, message, statusCode) still works for backward-compat', () => {
|
||||
expect(() => aliasError(makeRes(), 'msg', 400)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (2) Static tree: every callsite follows its file's imported convention ─
|
||||
describe('DC-063: routes/ + utilities/ arg-order matches each file\'s import', () => {
|
||||
function isNumericLiteral(s) {
|
||||
return /^\d+$/.test(s);
|
||||
}
|
||||
function isExpressionReturningNumber(s) {
|
||||
return /^(err|error|response)\.status(Code)?\s*\|\|.*\d+/.test(s) ||
|
||||
/^response\.status$/.test(s);
|
||||
}
|
||||
function isStringy(s) {
|
||||
s = s.trim();
|
||||
if (s.startsWith('"') || s.startsWith("'") || s.startsWith('`')) return true;
|
||||
if (/^[a-zA-Z_][a-zA-Z_0-9]*\([^)]*\)$/.test(s)) return true; // safeErrorMessage(err)
|
||||
if (/^[a-zA-Z_][a-zA-Z_0-9]*\.[a-zA-Z_][a-zA-Z_0-9.]*$/.test(s)) return true; // err.message
|
||||
return false;
|
||||
}
|
||||
function isNumeric(s) {
|
||||
return isNumericLiteral(s.trim()) || isExpressionReturningNumber(s.trim());
|
||||
}
|
||||
// Match `errorResponse(res, ARG1, ARG2)` (allow extras after).
|
||||
const pat = /errorResponse\(\s*res\s*,\s*([^,]+?)\s*,\s*([^,)\s]+)(?:\s*,|\s*\))/g;
|
||||
|
||||
const ROUTES = glob.sync('routes/*.js', { cwd: repoRoot });
|
||||
const UTILS = glob.sync('src/utilities/*.js', { cwd: repoRoot });
|
||||
const ALL = [...ROUTES, ...UTILS];
|
||||
|
||||
function classifyFile(src) {
|
||||
// Filter comments before classification (the comment can mention the alias).
|
||||
const codeOnly = src.split('\n')
|
||||
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
||||
.join('\n');
|
||||
const is_alias = /\berror:\s*errorResponse\b/.test(codeOnly);
|
||||
return { is_alias };
|
||||
}
|
||||
|
||||
test.each(ALL.map((rel) => [rel]))('%s has consistent callsite shape', (rel) => {
|
||||
const abs = path.join(repoRoot, rel);
|
||||
const src = fs.readFileSync(abs, 'utf8');
|
||||
const { is_alias } = classifyFile(src);
|
||||
const bad = [];
|
||||
for (const m of src.matchAll(pat)) {
|
||||
const a1 = m[1].trim();
|
||||
const a2 = m[2].trim();
|
||||
const lineNo = src.slice(0, m.index).split('\n').length;
|
||||
|
||||
if (is_alias) {
|
||||
// Convention B: arg1 = message (string), arg2 = status (number)
|
||||
if (isNumeric(a1) && isStringy(a2)) {
|
||||
bad.push({ lineNo, a1, a2, reason: 'alias-import + canonical-shape (BUG: alias path skips validator)' });
|
||||
}
|
||||
} else {
|
||||
// Convention A: arg1 = status (number), arg2 = message (string)
|
||||
if (isStringy(a1) && isNumeric(a2)) {
|
||||
bad.push({ lineNo, a1, a2, reason: 'canonical-import + alias-shape (BUG: validator fires TypeError -> 500 HTML)' });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bad.length) {
|
||||
throw new Error(
|
||||
`${rel}: ${bad.length} inconsistent callsite(s):\n` +
|
||||
bad.map((b) => ` L${b.lineNo}: (${b.a1}, ${b.a2}) — ${b.reason}`).join('\n')
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (3) End-to-end HTTP smoke — invalid input returns the expected JSON ─
|
||||
describe('DC-063: live HTTP smoke — security.js GET /events/:id returns 404 JSON (not 500)', () => {
|
||||
let server, baseUrl;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DASHCADDY_API_TOKEN = process.env.DASHCADDY_API_TOKEN || 'test-token';
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = process.env.DASHCADDY_ENCRYPTION_KEY || 'k'.repeat(64);
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'jwt-test-secret-32-chars-minimum-len';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Auth shim — bypass host authentication middleware.
|
||||
app.use((_req, _res, next) => next());
|
||||
|
||||
// Shim the security event store with a fake.
|
||||
const fakeStore = {
|
||||
get: () => null,
|
||||
append: () => ({ id: 'fake', accepted: true }),
|
||||
list: () => ({ events: [], total: 0 }),
|
||||
query: () => ({ events: [], total: 0 }),
|
||||
};
|
||||
const fakeRegistry = {
|
||||
list: () => [],
|
||||
register: () => ({ host: {}, api_key: 'x' }),
|
||||
get: () => null,
|
||||
update: () => null,
|
||||
remove: () => true,
|
||||
setEnabled: () => true,
|
||||
authHostByApiKey: () => null,
|
||||
authHostByBearer: () => null,
|
||||
};
|
||||
|
||||
// Inject store + registry via a require-cache swap so security.js's
|
||||
// getStore()/getRegistry() return our fakes.
|
||||
require.cache[path.join(repoRoot, 'src/security/event-store')] = {
|
||||
exports: { getStore: () => fakeStore },
|
||||
id: 'fake-event-store', filename: 'fake', loaded: true,
|
||||
};
|
||||
require.cache[path.join(repoRoot, 'src/security/host-registry')] = {
|
||||
exports: { getRegistry: () => fakeRegistry },
|
||||
id: 'fake-host-registry', filename: 'fake', loaded: true,
|
||||
};
|
||||
// platform-paths is required by security.js — provide a minimal shim.
|
||||
require.cache[path.join(repoRoot, 'platform-paths')] = {
|
||||
exports: { configFile: () => '/tmp/x', dataFile: () => '/tmp/y' },
|
||||
id: 'fake-platform-paths', filename: 'fake', loaded: true,
|
||||
};
|
||||
|
||||
const securityRoutes = require(path.join(repoRoot, 'routes/security'));
|
||||
app.use((req, res, next) => {
|
||||
res.success = (data) => res.json({ success: true, ...data });
|
||||
res.errorResponse = (status, msg, extras) => errorResponse(res, status, msg, extras);
|
||||
res.ok = (data) => res.json({ success: true, ...data });
|
||||
next();
|
||||
});
|
||||
app.use('/api/security', securityRoutes({
|
||||
store: fakeStore,
|
||||
registry: fakeRegistry,
|
||||
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||
}));
|
||||
|
||||
server = http.createServer(app).listen(0);
|
||||
// .listen(0) synchronously assigns a port; no need to wait.
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
afterAll((done) => {
|
||||
if (server && server.listening) server.close(done);
|
||||
else done();
|
||||
});
|
||||
|
||||
function get(p) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`${baseUrl}${p}`, (resp) => {
|
||||
let buf = '';
|
||||
resp.on('data', (c) => { buf += c; });
|
||||
resp.on('end', () => resolve({
|
||||
status: resp.statusCode,
|
||||
body: buf,
|
||||
contentType: resp.headers['content-type'] || '',
|
||||
}));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
test('GET /api/security/events/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
||||
const r = await get('/api/security/events/nonexistent');
|
||||
expect(r.status).toBe(404);
|
||||
expect(r.contentType).toMatch(/application\/json/);
|
||||
expect(r.body).toMatch(/event not found/i);
|
||||
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i); // NOT an HTML panic
|
||||
});
|
||||
|
||||
test('GET /api/security/hosts/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
||||
const r = await get('/api/security/hosts/nonexistent');
|
||||
expect(r.status).toBe(404);
|
||||
expect(r.contentType).toMatch(/application\/json/);
|
||||
expect(r.body).toMatch(/host not found/i);
|
||||
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* DC-068: Fleet SSRF hardening — routes-layer integration tests
|
||||
*
|
||||
* Verifies that:
|
||||
* - POST /api/v1/fleet/hosts rejects a public-DNS name that resolves to a
|
||||
* private IP (DNS rebinding defense)
|
||||
* - POST /api/v1/fleet/hosts accepts a public-DNS name that resolves to a
|
||||
* public IP and stores the resolved IP
|
||||
* - POST /api/v1/fleet/hosts rejects literal IPv4 in loopback / link-local
|
||||
* / RFC 1918 / CGNAT / broadcast ranges
|
||||
* - POST /api/v1/fleet/hosts accepts a literal public IPv4
|
||||
* - POST /api/v1/fleet/hosts rejects port 22 (SSH collision)
|
||||
* - POST /api/v1/fleet/hosts rejects control characters in name/tag
|
||||
* - POST /api/v1/fleet/hosts stores the resolved IP and dnsFamily so
|
||||
* /fleet/status and /fleet/deploy can probe by IP
|
||||
* - FLEET_ALLOW_PRIVATE_HOSTS=true opts in to private-range hosts
|
||||
*
|
||||
* The route tests live alongside the existing DC-108 suite in
|
||||
* caddycode-fleet.routes.test.js. We extend that file with two new describe
|
||||
* blocks so we can co-locate SSRF regression tests with their feature.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
function createFleetApp(log, opts = {}) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/fleet');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', routes({
|
||||
log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
asyncHandler: wrap,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('DC-068: Fleet POST /hosts — SSRF hardening', () => {
|
||||
let dnsBackup;
|
||||
let filePath;
|
||||
|
||||
beforeEach(() => {
|
||||
filePath = `/tmp/fleet-ssrf-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||
process.env.FLEET_HOSTS_FILE = filePath;
|
||||
dnsBackup = require('dns').promises.lookup;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
require('dns').promises.lookup = dnsBackup;
|
||||
delete process.env.FLEET_HOSTS_FILE;
|
||||
try { require('fs').unlinkSync(filePath); } catch {}
|
||||
delete process.env.FLEET_ALLOW_PRIVATE_HOSTS;
|
||||
});
|
||||
|
||||
it('rejects 127.0.0.1 (loopback) with PRIVATE_IPV4', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Local', hostname: '127.0.0.1', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
expect(res.body.error).toMatch(/loopback/i);
|
||||
});
|
||||
|
||||
it('rejects 169.254.169.254 (AWS IMDS)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'IMDS', hostname: '169.254.169.254', port: 80 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
expect(res.body.error).toMatch(/metadata|link-local/i);
|
||||
});
|
||||
|
||||
it('rejects 10.0.0.1 (RFC 1918)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'RFC1918', hostname: '10.0.0.1', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
expect(res.body.error).toMatch(/RFC 1918/);
|
||||
});
|
||||
|
||||
it('rejects 192.168.1.1 (LAN)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'LAN', hostname: '192.168.1.1', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
|
||||
it('rejects 100.64.0.1 (Tailscale CGNAT)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Tailscale', hostname: '100.64.0.1', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
|
||||
it('rejects ::1 (IPv6 loopback)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'v6loop', hostname: '::1', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV6');
|
||||
});
|
||||
|
||||
it('rejects port 22 (SSH)', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 22 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PORT');
|
||||
expect(res.body.error).toMatch(/22.*reserved|reserved.*22/);
|
||||
});
|
||||
|
||||
it('rejects port > 65535', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 65536 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PORT');
|
||||
});
|
||||
|
||||
it('rejects port = 0', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 0 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PORT');
|
||||
});
|
||||
|
||||
it('rejects garbage hostname', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'h', hostname: 'not a host!', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
|
||||
it('rejects control characters in name', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'evil\nname', hostname: 'fleet.example.com', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_NAME');
|
||||
});
|
||||
|
||||
it('rejects control characters in tags', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'h', hostname: 'fleet.example.com', port: 3001, tags: ['good', 'bad\ntag'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
|
||||
it('accepts a literal public IPv4', async () => {
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Public', hostname: '8.8.8.8', port: 3001 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.resolvedIp).toBe('8.8.8.8');
|
||||
expect(res.body.host.dnsFamily).toBe(4);
|
||||
});
|
||||
|
||||
it('accepts a public DNS name and resolves it', async () => {
|
||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Public DNS', hostname: 'public.example.com', port: 3001 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.hostname).toBe('public.example.com');
|
||||
expect(res.body.host.resolvedIp).toBe('93.184.216.34');
|
||||
expect(res.body.host.dnsFamily).toBe(4);
|
||||
});
|
||||
|
||||
it('rejects a DNS name that resolves to a private IP (DNS rebinding)', async () => {
|
||||
// Simulate a rebinding attacker: registration-time DNS returns a public
|
||||
// IP, but a follow-up resolve returns a loopback IP. We mock with the
|
||||
// private IP directly — the validator catches it at registration time.
|
||||
require('dns').promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Rebind', hostname: 'attacker.example.com', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
|
||||
it('opts in to private hosts when FLEET_ALLOW_PRIVATE_HOSTS=true', async () => {
|
||||
process.env.FLEET_ALLOW_PRIVATE_HOSTS = 'true';
|
||||
require('dns').promises.lookup = async () => [{ address: '100.100.50.25', family: 4 }];
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Tailscale', hostname: 'tailnet.example.com', port: 3001 });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.host.resolvedIp).toBe('100.100.50.25');
|
||||
});
|
||||
|
||||
it('rejects unresolvable DNS name', async () => {
|
||||
// .invalid is a guaranteed-non-resolving TLD per RFC 6761.
|
||||
const app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'NoDNS', hostname: 'does-not-resolve.invalid', port: 3001 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(res.body.code);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-068: Fleet GET /status — probes use resolved IP, not hostname', () => {
|
||||
let dnsBackup;
|
||||
let filePath;
|
||||
|
||||
beforeEach(() => {
|
||||
filePath = `/tmp/fleet-ssrf-status-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||
process.env.FLEET_HOSTS_FILE = filePath;
|
||||
dnsBackup = require('dns').promises.lookup;
|
||||
});
|
||||
afterEach(() => {
|
||||
require('dns').promises.lookup = dnsBackup;
|
||||
delete process.env.FLEET_HOSTS_FILE;
|
||||
try { require('fs').unlinkSync(filePath); } catch {}
|
||||
});
|
||||
|
||||
it('reports validation_failed for a stored host whose hostname resolves to a private IP', async () => {
|
||||
// Step 1: register a host with a public DNS name. Mock lookup so
|
||||
// registration succeeds.
|
||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
let app = createFleetApp();
|
||||
let res = await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Was Good', hostname: 'fleet.example.com', port: 3001 });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Step 2: flip the DNS to a private IP (simulating DNS rebinding).
|
||||
// Now GET /status should re-validate, detect the rebind, and tag the
|
||||
// host validation_failed instead of probing the internal address.
|
||||
require('dns').promises.lookup = async () => [{ address: '127.0.0.1', family: 4 }];
|
||||
app = createFleetApp();
|
||||
res = await request(app).get('/api/v1/fleet/status');
|
||||
expect(res.status).toBe(200);
|
||||
const host = res.body.hosts[0];
|
||||
expect(host.status).toBe('validation_failed');
|
||||
expect(host.validationError).toBeTruthy();
|
||||
expect(res.body.summary.validation_failed).toBe(1);
|
||||
expect(res.body.summary.offline).toBe(0);
|
||||
});
|
||||
|
||||
it('probes using stored resolvedIp, not raw hostname', async () => {
|
||||
// This is the route-level safety net: even if the stored resolvedIp
|
||||
// somehow no longer resolves correctly, /fleet/status must probe the
|
||||
// captured IP. We assert by checking the host.lastSeen / probe data is
|
||||
// driven by the resolved IP endpoint — but since we can't easily mock
|
||||
// fetch in this test, we verify the structural invariant: hosts with a
|
||||
// valid stored resolvedIp pass validation when DNS lookup ALSO returns
|
||||
// a public IP at probe time.
|
||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
let app = createFleetApp();
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
|
||||
app = createFleetApp();
|
||||
const res = await request(app).get('/api/v1/fleet/status');
|
||||
expect(res.status).toBe(200);
|
||||
// Status will be offline because the probed host (93.184.216.34:3001)
|
||||
// doesn't actually serve our health endpoint in the test environment —
|
||||
// but it should NOT be validation_failed.
|
||||
const host = res.body.hosts[0];
|
||||
expect(host.status).not.toBe('validation_failed');
|
||||
// The validation_failed counter should remain 0.
|
||||
expect(res.body.summary.validation_failed).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-068: Fleet POST /deploy — deployUrl uses resolvedIp', () => {
|
||||
let dnsBackup;
|
||||
let filePath;
|
||||
|
||||
beforeEach(() => {
|
||||
filePath = `/tmp/fleet-ssrf-deploy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`;
|
||||
process.env.FLEET_HOSTS_FILE = filePath;
|
||||
dnsBackup = require('dns').promises.lookup;
|
||||
});
|
||||
afterEach(() => {
|
||||
require('dns').promises.lookup = dnsBackup;
|
||||
delete process.env.FLEET_HOSTS_FILE;
|
||||
try { require('fs').unlinkSync(filePath); } catch {}
|
||||
});
|
||||
|
||||
it('emits deployUrl from the resolved IP, not the raw hostname', async () => {
|
||||
require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
let app = createFleetApp();
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 });
|
||||
app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan).toHaveLength(1);
|
||||
// The deployUrl was built from the resolved IP, not the user-supplied
|
||||
// hostname — defending against a DNS rebinding pivot at deploy time.
|
||||
expect(res.body.plan[0].deployUrl).toBe('http://93.184.216.34:3001/api/v1/apps/deploy');
|
||||
// The user-visible hostname is preserved on the plan entry.
|
||||
expect(res.body.plan[0].hostname).toBe('fleet.example.com');
|
||||
});
|
||||
|
||||
it('emits deployUrl from the literal IP for IPv4-literal hosts', async () => {
|
||||
const app = createFleetApp();
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'Literal', hostname: '8.8.8.8', port: 3001 });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan[0].deployUrl).toBe('http://8.8.8.8:3001/api/v1/apps/deploy');
|
||||
});
|
||||
|
||||
it('wraps IPv6 resolved IPs in [brackets] so the URL parses correctly', async () => {
|
||||
require('dns').promises.lookup = async () => [{ address: '2001:4860:4860::8888', family: 6 }];
|
||||
let app = createFleetApp();
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'v6 DNS', hostname: 'dns.example.com', port: 3001 });
|
||||
app = createFleetApp();
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
|
||||
});
|
||||
|
||||
it('wraps IPv6 literal hosts in [brackets]', async () => {
|
||||
const app = createFleetApp();
|
||||
await request(app)
|
||||
.post('/api/v1/fleet/hosts')
|
||||
.send({ name: 'v6', hostname: '2001:4860:4860::8888', port: 3001 });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/fleet/deploy')
|
||||
.send({ templateId: 'plex' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* DC-065: OpenClaw proxy hardening — test the four attack vectors closed
|
||||
* by the proxyRequest refactor:
|
||||
* (a) unbounded response passthrough → 5 MiB cap with 502 on overrun
|
||||
* (b) hop-by-hop + dangerous response-header passthrough → stripped
|
||||
* (c) malformed proxyRes.statusCode → coerced to 502
|
||||
* (d) unsafe `path` → 400 / 414 reject
|
||||
*
|
||||
* The route's helpers (sanitizeForwardedHeaders, coerceUpstreamStatus,
|
||||
* validatePath, plus the constants HOP_BY_HOP / STRIPPED_RESPONSE_HEADERS
|
||||
* / MAX_PROXY_RESPONSE_BYTES / MAX_PATH_LEN) are exposed on the returned
|
||||
* Express router under `router._dc065` for direct, hermetic unit testing
|
||||
* (no source-string parsing, no regex sandbox).
|
||||
*
|
||||
* End-to-end tests spin a real upstream http server on 127.0.0.1 to
|
||||
* exercise the proxy boundary through Express → openclaw router → http.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const express = require('express');
|
||||
|
||||
const openclawModule = require('../../routes/openclaw');
|
||||
|
||||
function makeRouter() {
|
||||
return openclawModule({
|
||||
docker: { client: { listContainers: async () => [] } },
|
||||
asyncHandler: (fn) => fn,
|
||||
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
|
||||
log: { info() {}, error() {}, warn() {}, debug() {} },
|
||||
});
|
||||
}
|
||||
|
||||
function spinUpstream(handler) {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer(handler);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('routes/openclaw — DC-065 proxy hardening', () => {
|
||||
describe('router shape (regression)', () => {
|
||||
test('router builds with /status, /deploy, /proxy/*, DELETE handlers and exposes _dc065 helpers', () => {
|
||||
const router = makeRouter();
|
||||
const paths = router.stack
|
||||
.filter((l) => l.route)
|
||||
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
||||
.flat();
|
||||
expect(paths).toEqual(expect.arrayContaining([
|
||||
'GET /status',
|
||||
'POST /deploy',
|
||||
'GET /proxy/*',
|
||||
'POST /proxy/*',
|
||||
'DELETE /',
|
||||
]));
|
||||
// DC-065 helper exposure — fails loud if a future refactor removes it.
|
||||
expect(router._dc065).toBeDefined();
|
||||
expect(typeof router._dc065.sanitizeForwardedHeaders).toBe('function');
|
||||
expect(typeof router._dc065.coerceUpstreamStatus).toBe('function');
|
||||
expect(typeof router._dc065.validatePath).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeForwardedHeaders (DC-065)', () => {
|
||||
let helpers;
|
||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
||||
|
||||
test('strips RFC 7230 hop-by-hop headers (case-insensitive)', () => {
|
||||
const input = {
|
||||
Connection: 'close',
|
||||
'keep-alive': 'timeout=5',
|
||||
'Proxy-Authenticate': 'Basic realm=...',
|
||||
'proxy-authorization': 'Basic foo',
|
||||
TE: 'trailers',
|
||||
Trailers: 'X-Foo',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
Upgrade: 'websocket',
|
||||
};
|
||||
expect(Object.keys(helpers.sanitizeForwardedHeaders(input))).toEqual([]);
|
||||
});
|
||||
|
||||
test('strips Set-Cookie / Content-Encoding / Content-Length / Server / X-Powered-By / Location / Refresh / WWW-Authenticate', () => {
|
||||
const input = {
|
||||
'Set-Cookie': 'sid=abc; HttpOnly',
|
||||
'Location': 'http://evil.com/steal', // DC-065 round-1 finding
|
||||
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2 finding
|
||||
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2 finding
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Length': '99999',
|
||||
'Server': 'openclaw/1.0',
|
||||
'X-Powered-By': 'openclaw',
|
||||
'X-Custom': 'kept',
|
||||
};
|
||||
const out = helpers.sanitizeForwardedHeaders(input);
|
||||
expect(Object.keys(out).sort()).toEqual(['X-Custom']);
|
||||
});
|
||||
|
||||
test('passes safe application/json + cache headers through unchanged', () => {
|
||||
const input = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Request-Id': 'req-123',
|
||||
};
|
||||
const out = helpers.sanitizeForwardedHeaders(input);
|
||||
expect(out['Content-Type']).toBe('application/json');
|
||||
expect(out['Cache-Control']).toBe('no-store');
|
||||
expect(out['X-Request-Id']).toBe('req-123');
|
||||
});
|
||||
|
||||
test('null/undefined input → empty object', () => {
|
||||
expect(helpers.sanitizeForwardedHeaders(null)).toEqual({});
|
||||
expect(helpers.sanitizeForwardedHeaders(undefined)).toEqual({});
|
||||
});
|
||||
|
||||
test('MAX_PROXY_RESPONSE_BYTES is 5 MiB', () => {
|
||||
expect(helpers.MAX_PROXY_RESPONSE_BYTES).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coerceUpstreamStatus (DC-065)', () => {
|
||||
let helpers;
|
||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
||||
|
||||
test('returns valid integer statuses 100..599 unchanged', () => {
|
||||
for (const s of [100, 200, 301, 404, 418, 500, 502, 503, 599]) {
|
||||
expect(helpers.coerceUpstreamStatus(s)).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
test('out-of-range integers coerce to 502', () => {
|
||||
expect(helpers.coerceUpstreamStatus(0)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(99)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(600)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(1000)).toBe(502);
|
||||
});
|
||||
|
||||
test('non-integer numbers coerce to 502', () => {
|
||||
expect(helpers.coerceUpstreamStatus(200.5)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(NaN)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(Infinity)).toBe(502);
|
||||
});
|
||||
|
||||
test('non-number types coerce to 502', () => {
|
||||
expect(helpers.coerceUpstreamStatus('200')).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(null)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus(undefined)).toBe(502);
|
||||
expect(helpers.coerceUpstreamStatus('OK')).toBe(502);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePath (DC-065)', () => {
|
||||
let helpers;
|
||||
beforeAll(() => { helpers = makeRouter()._dc065; });
|
||||
|
||||
test('rejects empty / non-string / oversize paths', () => {
|
||||
expect(helpers.validatePath('').ok).toBe(false);
|
||||
expect(helpers.validatePath(null).ok).toBe(false);
|
||||
expect(helpers.validatePath(undefined).ok).toBe(false);
|
||||
expect(helpers.validatePath(123).ok).toBe(false);
|
||||
const long = '/' + 'a'.repeat(helpers.MAX_PATH_LEN);
|
||||
const r = helpers.validatePath(long);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe(414);
|
||||
});
|
||||
|
||||
test('rejects absolute-URL injection (`://`)', () => {
|
||||
const r = helpers.validatePath('foo://127.0.0.1:6379/steal');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects whitespace / backslash / CR/LF', () => {
|
||||
expect(helpers.validatePath('foo bar').ok).toBe(false);
|
||||
expect(helpers.validatePath('foo\r\nbar').ok).toBe(false);
|
||||
expect(helpers.validatePath('foo\\bar').ok).toBe(false);
|
||||
expect(helpers.validatePath('foo\tbar').ok).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts RFC 3986 pchar + query separators', () => {
|
||||
// Real-world path sent by a browser: query string starts with `?`.
|
||||
// (Fragments `#frag` are stripped by the browser before reaching
|
||||
// the server — we don't need to allow them.)
|
||||
const ok = helpers.validatePath('/api/v1/chat?msg=hi&x=y');
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(ok.normalized).toBe('api/v1/chat?msg=hi&x=y');
|
||||
});
|
||||
|
||||
test('strips multiple leading slashes idempotently', () => {
|
||||
const ok = helpers.validatePath('///foo/bar');
|
||||
expect(ok.ok).toBe(true);
|
||||
expect(ok.normalized).toBe('foo/bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('end-to-end via /openclaw/proxy/* (DC-065 integration)', () => {
|
||||
// Helper: build an express app mounted with the openclaw router and
|
||||
// a docker stub that returns the provided upstream port.
|
||||
function buildProxyApp(upstreamPort) {
|
||||
const fakeContainer = {
|
||||
Id: 'a'.repeat(64),
|
||||
Image: 'ghcr.io/nousresearch/openclaw:latest',
|
||||
Names: ['/openclaw-test'],
|
||||
State: 'running',
|
||||
Status: 'Up',
|
||||
Created: 1700000000,
|
||||
Labels: { 'dashcaddy.managed': 'true', 'dashcaddy.app': 'openclaw' },
|
||||
Ports: [{ PrivatePort: 18792, PublicPort: upstreamPort }],
|
||||
};
|
||||
const app = express();
|
||||
app.disable('x-powered-by'); // mirror src/app.js line 139
|
||||
app.disable('etag');
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => {
|
||||
res.ok = (data, code) => res.status(code || 200).json({ success: true, ...data });
|
||||
res.errorResponse = (msg, code, extras) =>
|
||||
res.status(code || 500).json({ success: false, error: msg, ...(extras || {}) });
|
||||
res.notFound = (msg) => res.status(404).json({ success: false, error: msg });
|
||||
res.conflict = (msg) => res.status(409).json({ success: false, error: msg });
|
||||
next();
|
||||
});
|
||||
const router = openclawModule({
|
||||
docker: {
|
||||
client: {
|
||||
listContainers: async () => [fakeContainer],
|
||||
containerInfo: async () => ({ Config: { Env: ['OPENCLAW_GATEWAY_TOKEN=test-token'] } }),
|
||||
},
|
||||
},
|
||||
asyncHandler: (fn) => fn,
|
||||
ok: (res, data, code) => res.status(code || 200).json({ success: true, ...data }),
|
||||
log: { info() {}, error() {}, warn() {}, debug() {} },
|
||||
});
|
||||
app.use('/openclaw', router);
|
||||
return app;
|
||||
}
|
||||
|
||||
function listen(app) {
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, () => {
|
||||
const { port } = server.address();
|
||||
resolve({ server, port, close: () => new Promise((r) => server.close(r)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('caps an oversized upstream response with 502 + DC-065 message', async () => {
|
||||
const upstream = await spinUpstream((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
// 6 MiB single chunk — proxy caps at 5 MiB.
|
||||
res.write(Buffer.alloc(6 * 1024 * 1024, 0x41));
|
||||
res.end();
|
||||
});
|
||||
try {
|
||||
const app = buildProxyApp(upstream.port);
|
||||
const { server, port, close } = await listen(app);
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/health`);
|
||||
expect(r.status).toBe(502);
|
||||
const text = await r.text();
|
||||
expect(text).toMatch(/DC-065|upstream/g);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test('forwards safe upstream headers; strips Set-Cookie / Transfer-Encoding / Content-Encoding / Location / Refresh / WWW-Authenticate', async () => {
|
||||
const upstream = await spinUpstream((req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
// These must NOT cross the proxy to the browser:
|
||||
'Transfer-Encoding': 'chunked',
|
||||
'Upgrade': 'websocket',
|
||||
'Set-Cookie': 'sid=steal; HttpOnly',
|
||||
'Location': 'http://evil.com/steal', // DC-065 round-1
|
||||
'Refresh': '0; url=http://evil.com/steal', // DC-065 round-2
|
||||
'WWW-Authenticate': 'Basic realm="OpenClaw"', // DC-065 round-2
|
||||
'Content-Encoding': 'gzip',
|
||||
'Server': 'openclaw/1.0',
|
||||
'X-Powered-By': 'openclaw',
|
||||
});
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
try {
|
||||
const app = buildProxyApp(upstream.port);
|
||||
const { server, port, close } = await listen(app);
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/status`);
|
||||
expect(r.status).toBe(200);
|
||||
// Node's http server may emit Connection/Keep-Alive of its own
|
||||
// accord (HTTP/1.1 keep-alive defaults), so we don't gate on those.
|
||||
// We DO gate on the ten upstream-shaping headers our sanitizer
|
||||
// explicitly removes — see sanitizeForwardedHeaders().
|
||||
for (const forbidden of [
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
'set-cookie',
|
||||
'location',
|
||||
'refresh',
|
||||
'www-authenticate',
|
||||
'content-encoding',
|
||||
'server',
|
||||
'x-powered-by',
|
||||
// content-length: Node sets it automatically when we buffer + end(),
|
||||
// so we cannot test that the upstream's CL header is stripped — but
|
||||
// we ARE stripping it from the forwarded headers, verified by
|
||||
// sanitization unit tests above.
|
||||
]) {
|
||||
expect(r.headers.get(forbidden)).toBeNull();
|
||||
}
|
||||
expect(r.headers.get('content-type')).toMatch(/^application\/json/);
|
||||
expect(r.headers.get('cache-control')).toBe('no-store');
|
||||
const body = await r.json();
|
||||
expect(body.ok).toBe(true);
|
||||
void server;
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
test('rejects path with `://` injection via 400', async () => {
|
||||
// Upstream on any port — the validator must reject BEFORE we dial it.
|
||||
const upstream = await spinUpstream(() => {
|
||||
throw new Error('should not reach upstream on reject path');
|
||||
});
|
||||
try {
|
||||
const app = buildProxyApp(upstream.port);
|
||||
const { server, port, close } = await listen(app);
|
||||
try {
|
||||
// URL-decoded `foo://127.0.0.1` → forbidden char `://` → 400.
|
||||
const r = await fetch(`http://127.0.0.1:${port}/openclaw/proxy/foo%3A%2F%2F127.0.0.1`);
|
||||
expect(r.status).toBe(400);
|
||||
const body = await r.json();
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toMatch(/forbidden|disallowed/i);
|
||||
void server;
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
@@ -34,14 +34,23 @@ jest.mock('../../src/utilities/pagination', () => ({
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/responses', () => ({
|
||||
success: jest.fn((res, data, statusCode = 200) => {
|
||||
return res.status(statusCode).json({ success: true, ...data });
|
||||
}),
|
||||
error: jest.fn((res, message, statusCode = 500, extra) => {
|
||||
return res.status(statusCode).json({ success: false, error: message, ...extra });
|
||||
}),
|
||||
}));
|
||||
jest.mock('../../src/utils/responses', () => {
|
||||
// DC-063: services.js now imports canonical `errorResponse` (statusCode-first),
|
||||
// so this mock must expose both that AND the legacy `error` alias to keep the
|
||||
// existing fixture working. The canonical validator is bypassed (tests use it
|
||||
// as a structured passthrough); the alias preserves call-shape for any
|
||||
// remaining legacy import.
|
||||
const errorResponse = jest.fn((res, statusCode, message, extra) =>
|
||||
res.status(statusCode).json({ success: false, error: message, ...extra })
|
||||
);
|
||||
return {
|
||||
success: jest.fn((res, data, statusCode = 200) =>
|
||||
res.status(statusCode).json({ success: true, ...data })
|
||||
),
|
||||
errorResponse,
|
||||
error: errorResponse, // alias used by files that import `error: errorResponse`
|
||||
};
|
||||
});
|
||||
|
||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* DC-068: Fleet hostname SSRF hardening
|
||||
*
|
||||
* Tests for the fleet validation helpers (isPrivateOrReservedIPv4/IPv6,
|
||||
* isValidHostnameSyntax, validateFleetHost) and resolveAndCheckAddress.
|
||||
* Covers:
|
||||
* - IPv4 private/reserved range detection (loopback, link-local, RFC 1918,
|
||||
* CGNAT, multicast, broadcast, documentation)
|
||||
* - IPv6 private/reserved range detection (loopback, link-local, ULA,
|
||||
* multicast, IPv4-mapped)
|
||||
* - RFC 1123 hostname syntax check
|
||||
* - Port bounds (1..65535), port 22 rejection, missing/invalid port
|
||||
* - Tag validation (max 20, each 1..50, no control chars)
|
||||
* - Name validation (1..100, no control chars)
|
||||
* - End-to-end validateFleetHost for all rejection and acceptance paths
|
||||
* - resolveAndCheckAddress: literal IP paths, DNS-resolution success path
|
||||
* with mocked dns.lookup, DNS-resolution failure path, and the
|
||||
* allow-private opt-in
|
||||
*
|
||||
* The DNS path is unit-tested by replacing `dns.promises.lookup` on the
|
||||
* module instance with a mock that returns a fake A record.
|
||||
*/
|
||||
const {
|
||||
validateFleetHost,
|
||||
resolveAndCheckAddress,
|
||||
isPrivateOrReservedIPv4,
|
||||
isPrivateOrReservedIPv6,
|
||||
isValidHostnameSyntax,
|
||||
} = require('../src/utilities/fleet-validation');
|
||||
|
||||
describe('DC-068: isPrivateOrReservedIPv4', () => {
|
||||
const cases = [
|
||||
// [ip, expectedIsPrivate, expectedLabelSubstring-or-null]
|
||||
['127.0.0.1', true, 'loopback'],
|
||||
['127.255.255.1', true, 'loopback'],
|
||||
['169.254.0.1', true, 'link-local'],
|
||||
['169.254.169.254',true, 'link-local'], // AWS/GCP/Azure metadata
|
||||
['10.0.0.1', true, 'RFC 1918'],
|
||||
['172.16.0.1', true, 'RFC 1918'],
|
||||
['172.31.255.1', true, 'RFC 1918'],
|
||||
['172.32.0.1', false, null],
|
||||
['192.168.1.1', true, 'RFC 1918'],
|
||||
['100.64.0.1', true, 'CGNAT'],
|
||||
['100.127.255.1', true, 'CGNAT'],
|
||||
['100.128.0.1', false, null],
|
||||
['224.0.0.1', true, 'multicast'],
|
||||
['239.255.255.255',true, 'multicast'],
|
||||
['255.255.255.255',true, 'broadcast'],
|
||||
['0.0.0.0', true, 'reserved'],
|
||||
['192.0.2.1', true, 'TEST-NET-1'],
|
||||
['198.51.100.1', true, 'TEST-NET-2'],
|
||||
['203.0.113.1', true, 'TEST-NET-3'],
|
||||
['198.18.0.1', true, 'benchmark'],
|
||||
['198.19.255.1', true, 'benchmark'],
|
||||
['240.0.0.1', true, 'reserved'],
|
||||
['8.8.8.8', false, null],
|
||||
['1.1.1.1', false, null],
|
||||
['93.184.216.34', false, null],
|
||||
];
|
||||
for (const [ip, wantPrivate, wantLabel] of cases) {
|
||||
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
|
||||
const r = isPrivateOrReservedIPv4(ip);
|
||||
expect(r.isPrivate).toBe(wantPrivate);
|
||||
if (wantLabel) expect(r.label).toContain(wantLabel);
|
||||
else expect(r.label).toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
it('returns isPrivate=false for non-strings', () => {
|
||||
expect(isPrivateOrReservedIPv4(null).isPrivate).toBe(false);
|
||||
expect(isPrivateOrReservedIPv4(undefined).isPrivate).toBe(false);
|
||||
expect(isPrivateOrReservedIPv4(42).isPrivate).toBe(false);
|
||||
});
|
||||
it('returns isPrivate=false for malformed IPv4', () => {
|
||||
expect(isPrivateOrReservedIPv4('1.2.3').isPrivate).toBe(false);
|
||||
expect(isPrivateOrReservedIPv4('1.2.3.4.5').isPrivate).toBe(false);
|
||||
expect(isPrivateOrReservedIPv4('256.0.0.0').isPrivate).toBe(false);
|
||||
expect(isPrivateOrReservedIPv4('1.2.3.999').isPrivate).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-068: isPrivateOrReservedIPv6', () => {
|
||||
const cases = [
|
||||
['::1', true, 'IPv6 loopback'],
|
||||
['::', true, 'IPv6 unspecified'],
|
||||
['fe80::1', true, 'link-local'],
|
||||
['feb0::1', true, 'link-local'],
|
||||
['fc00::1', true, 'unique-local'],
|
||||
['fd00::1', true, 'unique-local'],
|
||||
['ff00::1', true, 'multicast'],
|
||||
['::ffff:127.0.0.1',true, 'IPv4-mapped'],
|
||||
['::ffff:8.8.8.8',false, null],
|
||||
['2001:4860:4860::8888',false, null], // Google IPv6
|
||||
['2606:4700:4700::1111',false, null], // Cloudflare IPv6
|
||||
];
|
||||
for (const [ip, wantPrivate, wantLabel] of cases) {
|
||||
it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => {
|
||||
const r = isPrivateOrReservedIPv6(ip);
|
||||
expect(r.isPrivate).toBe(wantPrivate);
|
||||
if (wantLabel) expect(r.label).toContain(wantLabel);
|
||||
else expect(r.label).toBeNull();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('DC-068: isValidHostnameSyntax', () => {
|
||||
const accept = [
|
||||
'example.com',
|
||||
'sub.example.com',
|
||||
'a-b.example.com',
|
||||
'host1',
|
||||
'a',
|
||||
'a'.repeat(63) + '.com', // 63-char label is the max
|
||||
'very-long-host-name-with-many-segments.sub.example.com',
|
||||
'host-with-trailing-dot.', // trailing dot is legal
|
||||
'EXAMPLE.com', // case-insensitive
|
||||
'123.example.com', // numeric labels allowed
|
||||
];
|
||||
for (const h of accept) {
|
||||
it(`accepts "${h}"`, () => {
|
||||
expect(isValidHostnameSyntax(h)).toBe(true);
|
||||
});
|
||||
}
|
||||
const reject = [
|
||||
'',
|
||||
'.',
|
||||
'..',
|
||||
'a..b', // empty label
|
||||
'-a.com', // label can't start with hyphen
|
||||
'a-.com', // label can't end with hyphen
|
||||
'a b.com', // space not allowed
|
||||
'_underscore.com', // underscore not allowed (strict RFC 1123)
|
||||
'a/b.com', // slash not allowed
|
||||
'a$b.com', // dollar not allowed
|
||||
'a.com/' + 'x'.repeat(255), // 255-char label exceeds 63
|
||||
'host.with.' + 'a-63-chars-'.repeat(8) + '.com', // total > 253 chars
|
||||
];
|
||||
for (const h of reject) {
|
||||
it(`rejects "${h}"`, () => {
|
||||
expect(isValidHostnameSyntax(h)).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('DC-068: validateFleetHost', () => {
|
||||
const valid = (extra = {}) => ({
|
||||
name: 'Test Host',
|
||||
hostname: 'fleet.example.com',
|
||||
port: 3001,
|
||||
tags: ['prod'],
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('accepts a clean public-DNS host', () => {
|
||||
const r = validateFleetHost(valid());
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized.name).toBe('Test Host');
|
||||
expect(r.normalized.hostname).toBe('fleet.example.com');
|
||||
expect(r.normalized.port).toBe(3001);
|
||||
});
|
||||
|
||||
it('normalises hostname to lowercase and trims name', () => {
|
||||
const r = validateFleetHost({ ...valid(), name: ' Spaced ', hostname: 'FLEET.Example.COM' });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized.name).toBe('Spaced');
|
||||
expect(r.normalized.hostname).toBe('fleet.example.com');
|
||||
});
|
||||
|
||||
it('accepts a public IPv4 literal', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: '8.8.8.8' });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a public IPv6 literal', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: '2001:4860:4860::8888' });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
// ── Name rejection paths ──
|
||||
it('rejects missing name with INVALID_NAME', () => {
|
||||
const r = validateFleetHost({ ...valid(), name: undefined });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_NAME');
|
||||
});
|
||||
it('rejects empty name', () => {
|
||||
const r = validateFleetHost({ ...valid(), name: '' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_NAME');
|
||||
});
|
||||
it('rejects name >100 chars', () => {
|
||||
const r = validateFleetHost({ ...valid(), name: 'x'.repeat(101) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_NAME');
|
||||
});
|
||||
it('rejects name with control characters', () => {
|
||||
expect(validateFleetHost({ ...valid(), name: 'evil\nname' }).code).toBe('INVALID_NAME');
|
||||
expect(validateFleetHost({ ...valid(), name: 'evil\rname' }).code).toBe('INVALID_NAME');
|
||||
expect(validateFleetHost({ ...valid(), name: 'evil\x00name' }).code).toBe('INVALID_NAME');
|
||||
});
|
||||
|
||||
// ── Hostname rejection paths ──
|
||||
it('rejects missing hostname with INVALID_HOSTNAME', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: undefined });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
it('rejects empty hostname', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: '' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
it('rejects garbage hostname', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: 'not a valid host' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
it('rejects hostname with scheme prefix (url injection)', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: 'http://evil.com' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
it('rejects hostname with @ (URL-credential injection)', () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: 'evil@host.com' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
|
||||
// ── IPv4 private-range rejection paths (literal input) ──
|
||||
const privateV4 = [
|
||||
['127.0.0.1', 'loopback'],
|
||||
['169.254.169.254', 'link-local'],
|
||||
['10.0.0.1', 'RFC 1918'],
|
||||
['192.168.1.1', 'RFC 1918'],
|
||||
['100.64.0.1', 'CGNAT'], // Tailscale
|
||||
['255.255.255.255', 'broadcast'],
|
||||
['0.0.0.0', 'reserved'],
|
||||
];
|
||||
for (const [ip, label] of privateV4) {
|
||||
it(`rejects private IPv4 literal ${ip} (${label})`, () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: ip });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
expect(r.message).toContain(label);
|
||||
});
|
||||
}
|
||||
|
||||
// ── IPv6 private-range rejection paths ──
|
||||
const privateV6 = [
|
||||
['::1', 'IPv6 loopback'],
|
||||
['fe80::1', 'IPv6 link-local'],
|
||||
['fc00::1', 'IPv6 unique-local'],
|
||||
['fd00::abcd', 'IPv6 unique-local'],
|
||||
['::ffff:127.0.0.1', 'IPv4-mapped'], // contains BOTH colon AND dot
|
||||
];
|
||||
for (const [ip, label] of privateV6) {
|
||||
it(`rejects private IPv6 literal ${ip} (${label})`, () => {
|
||||
const r = validateFleetHost({ ...valid(), hostname: ip });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV6');
|
||||
expect(r.message).toContain(label);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Port rejection paths ──
|
||||
it('rejects port < 1', () => {
|
||||
const r = validateFleetHost({ ...valid(), port: 0 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_PORT');
|
||||
});
|
||||
it('rejects port > 65535', () => {
|
||||
const r = validateFleetHost({ ...valid(), port: 65536 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_PORT');
|
||||
});
|
||||
it('rejects non-integer port', () => {
|
||||
expect(validateFleetHost({ ...valid(), port: 'three' }).code).toBe('INVALID_PORT');
|
||||
expect(validateFleetHost({ ...valid(), port: 3001.5 }).code).toBe('INVALID_PORT');
|
||||
});
|
||||
it('rejects port 22 (SSH collision)', () => {
|
||||
const r = validateFleetHost({ ...valid(), port: 22 });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_PORT');
|
||||
expect(r.message).toMatch(/22.*reserved|reserved.*22/);
|
||||
});
|
||||
it('accepts port 1, 1023, 1024, 65535', () => {
|
||||
expect(validateFleetHost({ ...valid(), port: 1 }).ok).toBe(true);
|
||||
expect(validateFleetHost({ ...valid(), port: 1023 }).ok).toBe(true);
|
||||
expect(validateFleetHost({ ...valid(), port: 1024 }).ok).toBe(true);
|
||||
expect(validateFleetHost({ ...valid(), port: 65535 }).ok).toBe(true);
|
||||
});
|
||||
|
||||
// ── Tag rejection paths ──
|
||||
it('rejects non-array tags', () => {
|
||||
const r = validateFleetHost({ ...valid(), tags: 'prod' });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
it('rejects > 20 tags', () => {
|
||||
const r = validateFleetHost({ ...valid(), tags: Array.from({ length: 21 }, (_, i) => `t${i}`) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
it('rejects empty-string tag', () => {
|
||||
const r = validateFleetHost({ ...valid(), tags: ['valid', ''] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
it('rejects tag > 50 chars', () => {
|
||||
const r = validateFleetHost({ ...valid(), tags: ['x'.repeat(51)] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
it('rejects tag with control characters', () => {
|
||||
const r = validateFleetHost({ ...valid(), tags: ['good', 'bad\ntag'] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_TAGS');
|
||||
});
|
||||
it('accepts tags omitted (defaults to [])', () => {
|
||||
const r = validateFleetHost({ name: 'h', hostname: 'fleet.example.com', port: 3001 });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.normalized.tags).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DC-068: resolveAndCheckAddress', () => {
|
||||
// The DNS code path uses `dns.promises.lookup` directly; for literal IPs
|
||||
// and IPv6, no DNS call is made. The DNS-name code path is exercised by
|
||||
// mocking dns.promises.lookup.
|
||||
|
||||
it('accepts a public IPv4 literal without DNS lookup', async () => {
|
||||
const r = await resolveAndCheckAddress('8.8.8.8');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.ip).toBe('8.8.8.8');
|
||||
expect(r.family).toBe(4);
|
||||
});
|
||||
|
||||
it('accepts a public IPv6 literal', async () => {
|
||||
const r = await resolveAndCheckAddress('2001:4860:4860::8888');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.ip).toBe('2001:4860:4860::8888');
|
||||
expect(r.family).toBe(6);
|
||||
});
|
||||
|
||||
it('rejects a private IPv4 literal with opt-out', async () => {
|
||||
const r = await resolveAndCheckAddress('127.0.0.1');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
|
||||
it('accepts a private IPv4 literal when allowPrivate=true', async () => {
|
||||
const r = await resolveAndCheckAddress('192.168.1.1', { allowPrivate: true });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.ip).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('rejects a Tailscale (CGNAT) IPv4 literal', async () => {
|
||||
const r = await resolveAndCheckAddress('100.64.0.1');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
|
||||
it('rejects the AWS metadata endpoint 169.254.169.254', async () => {
|
||||
const r = await resolveAndCheckAddress('169.254.169.254');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
expect(r.message).toMatch(/link-local|metadata/i);
|
||||
});
|
||||
|
||||
it('rejects IPv4-mapped IPv6 loopback', async () => {
|
||||
const r = await resolveAndCheckAddress('::ffff:127.0.0.1');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV6');
|
||||
});
|
||||
|
||||
it('rejects garbage hostnames without DNS lookup', async () => {
|
||||
const r = await resolveAndCheckAddress('not a host');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
|
||||
it('rejects empty hostname', async () => {
|
||||
const r = await resolveAndCheckAddress('');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOSTNAME');
|
||||
});
|
||||
|
||||
it('rejects DNS name that does not resolve', async () => {
|
||||
// We use a reserved TLD (.invalid) which RFC 6761 guarantees will not
|
||||
// resolve in production DNS — so the test is hermetic without mocking.
|
||||
const r = await resolveAndCheckAddress('does-not-resolve.invalid');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(r.code);
|
||||
});
|
||||
|
||||
it('rejects DNS name that resolves to a private IP', async () => {
|
||||
// Heremetic test: dns.promises.lookup is patched on the module instance.
|
||||
const dns = require('dns');
|
||||
const originalLookup = dns.promises.lookup;
|
||||
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
||||
try {
|
||||
const r = await resolveAndCheckAddress('attacker.example.com');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
} finally {
|
||||
dns.promises.lookup = originalLookup;
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts DNS name that resolves to a public IP', async () => {
|
||||
const dns = require('dns');
|
||||
const originalLookup = dns.promises.lookup;
|
||||
dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }];
|
||||
try {
|
||||
const r = await resolveAndCheckAddress('public.example.com');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.ip).toBe('93.184.216.34');
|
||||
expect(r.family).toBe(4);
|
||||
} finally {
|
||||
dns.promises.lookup = originalLookup;
|
||||
}
|
||||
});
|
||||
|
||||
it('skips private check when allowPrivate=true even for DNS-resolved address', async () => {
|
||||
const dns = require('dns');
|
||||
const originalLookup = dns.promises.lookup;
|
||||
dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }];
|
||||
try {
|
||||
const r = await resolveAndCheckAddress('tailnet.example.com', { allowPrivate: true });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.ip).toBe('10.0.0.5');
|
||||
} finally {
|
||||
dns.promises.lookup = originalLookup;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Caddy admin API IPv6-origin allowlist tests — DC-069
|
||||
*
|
||||
* Regression for the live 403 spam observed on DNS2 after DC-051 was shipped:
|
||||
*
|
||||
* `{"error":"client is not allowed to access from origin ''","status_code":403}`
|
||||
*
|
||||
* from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=::1, hitting
|
||||
* `/config/apps/http/servers/srv0/listen` from various ports with bursts of
|
||||
* 5-10 requests every ~30s while some on-host Node caller (e.g. a future
|
||||
* status/api/caddy-api.js process) probes Caddy admin via `localhost:2019`.
|
||||
*
|
||||
* Root cause: DC-051 added `origins http://localhost:2019 http://127.0.0.1:2019
|
||||
* http://172.17.0.1:2019 http://0.0.0.0:2019` to the Caddyfile's admin block,
|
||||
* but per glibc RFC 3484 / `getaddrinfo` on Linux, `localhost` resolves to
|
||||
* `::1` FIRST when `/etc/hosts` has `::1 localhost` (which every modern Linux
|
||||
* distro does, including DNS2's). When the Node caller does
|
||||
* `http.get('http://localhost:2019/...')`, undici's dns.lookup picks the
|
||||
* IPv6 address, the request reaches Caddy over IPv6 loopback with the
|
||||
* Origin header the caller (or our _httpFetch helper) computed as
|
||||
* `http://localhost:2019`. Caddy's enforce_origin allowlist exact-matches
|
||||
* Origin strings against the configured list — and `http://localhost:2019`
|
||||
* ≠ `http://[::1]:2019`, so the request is rejected with the empty-Origin-
|
||||
* is-403 path (because Caddy's documented behavior is: an EMPTY Origin and
|
||||
* a non-allowlisted Origin both fall through to 403 "client is not allowed
|
||||
* to access from origin ''").
|
||||
*
|
||||
* The fix has 3 pieces:
|
||||
*
|
||||
* 1. Extend the Caddyfile's `origins` allowlist with the IPv6 literal
|
||||
* `http://[::1]:2019` (and `http://ip6-localhost:2019` for the glibc
|
||||
* alias), so that a Node caller resolving `localhost` to `::1` is
|
||||
* matched by its `http://localhost:2019` Origin AS LONG AS — and this
|
||||
* is the critical detail — the caller's URL string is literally
|
||||
* `http://localhost:2019` (Origin matches by string, not by IP). The
|
||||
* same applies to the `http://[::1]:2019` form which is what the
|
||||
* _httpFetch helper auto-injects when the parsed hostname is `::1`.
|
||||
*
|
||||
* 2. Mirror the fix into `dashcaddy-installer/templates/Caddyfile.template`
|
||||
* by documenting the IPv6 entry in the comment header for the admin
|
||||
* block, so a future operator adopting a non-loopback admin bind sees
|
||||
* the complete pattern (4 IPv4 + 2 IPv6 entries).
|
||||
*
|
||||
* 3. Extend the DC-051 `utils-http-caddy-admin-origin.test.js` regression
|
||||
* to assert that the template's comment block DOES mention IPv6 (so it
|
||||
* stays updated), and that the live DNS2 Caddyfile has the IPv6 entry.
|
||||
* The latter can't be unit-tested (no DNS2 filesystem access from a
|
||||
* unit test), so this file ships an end-to-end check that asserts the
|
||||
* template comment block — covering the half that IS in the repo —
|
||||
* while DC-051's test continues to guard the live-deploy half.
|
||||
*
|
||||
* Threat model verified: the IPv6 loopback [::1] is the SAME trust zone as
|
||||
* 127.0.0.1 — both are loopback, both can only be reached by processes that
|
||||
* already have shell on the host, so adding them to the allowlist does NOT
|
||||
* increase attack surface. Tailscale IPs and the docker bridge IP are
|
||||
* unchanged (http://100.121.150.22:2019 stays out — only loopback allowed).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Sentinel prefix used to mark template literals while we strip comments.
|
||||
// Control characters (\u0000 = NUL) are used to make accidental collisions
|
||||
// with real code extremely unlikely. Note: ESLint's no-control-regex
|
||||
// forbids these characters inside `/regex/` literals, so we build the
|
||||
// sentinel via string concat at call time instead of as a regex.
|
||||
function stripComments(src) {
|
||||
// Same helper used by the DC-051 test file — duplicated here to keep the
|
||||
// two test files independent (a test file should NOT depend on another
|
||||
// test file's exports; the convention in this repo is one test file per
|
||||
// concern with its own helpers).
|
||||
const NUL = String.fromCharCode(0);
|
||||
const templates = [];
|
||||
let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => {
|
||||
const idx = templates.length;
|
||||
templates.push(match);
|
||||
return NUL + 'TPL' + idx + NUL;
|
||||
});
|
||||
protectedSrc = protectedSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
// Restore template literals using a non-regex split — eslint friendly.
|
||||
const out = [];
|
||||
let i = 0;
|
||||
while (i < protectedSrc.length) {
|
||||
const start = protectedSrc.indexOf(NUL + 'TPL', i);
|
||||
if (start < 0) { out.push(protectedSrc.slice(i)); break; }
|
||||
out.push(protectedSrc.slice(i, start));
|
||||
const mid = start + 4;
|
||||
const end = protectedSrc.indexOf(NUL, mid);
|
||||
if (end < 0) { out.push(protectedSrc.slice(start)); break; }
|
||||
out.push(templates[+protectedSrc.slice(mid, end)]);
|
||||
i = end + 1;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
describe('Caddy admin IPv6 origin allowlist (DC-069)', () => {
|
||||
test('Caddyfile template comment mentions IPv6 localhost ([::1]) for non-loopback admin', () => {
|
||||
// The template currently ships `admin localhost:2019` (loopback bind,
|
||||
// no enforce_origin needed), but operators following the documented
|
||||
// DNS2-style non-loopback bind need to know the IPv6 entry is part
|
||||
// of the allowlist. We assert the COMMENT block mentions IPv6 so any
|
||||
// future refactor keeps the docblock honest.
|
||||
const tmplPath = path.join(__dirname, '../../dashcaddy-installer/templates/Caddyfile.template');
|
||||
if (!fs.existsSync(tmplPath)) {
|
||||
console.warn('Skipping Caddyfile template check — not present at', tmplPath);
|
||||
return;
|
||||
}
|
||||
const raw = fs.readFileSync(tmplPath, 'utf8');
|
||||
// Looking at the RAW (with comments) form is the entire point of this
|
||||
// assertion: comment-only edits are exactly what gets lost in refactors.
|
||||
expect(raw).toMatch(/\[::1\]|::1|ip6-localhost|IPv6|ipv6/);
|
||||
});
|
||||
|
||||
test('helper sanity: stripComments preserves template literals with // inside', () => {
|
||||
// Internal regression: the stripComments helper has a known subtle
|
||||
// behavior — it must NOT eat the `//` that occurs in URLs inside
|
||||
// template literals. This test guards the helper so any future
|
||||
// simplification of it breaks here loudly, not at the assertion
|
||||
// below.
|
||||
const sample = 'const x = `http://${h}:${p}/foo`;\n// a real comment\nconst y = 1;\n';
|
||||
const stripped = stripComments(sample);
|
||||
expect(stripped).toContain('`http://${h}:${p}/foo`');
|
||||
expect(stripped).not.toContain('// a real comment');
|
||||
});
|
||||
|
||||
test('end-to-end probe on IPv6 loopback [::1]:2019 with matching Origin succeeds', async () => {
|
||||
// The actual bug: when a Node caller hits Caddy via `[::1]:2019`, the
|
||||
// Origin header it computes from the parsed URL is
|
||||
// `http://[::1]:2019`. Caddy's enforce_origin allowlist must contain
|
||||
// that EXACT string for the request to succeed. This end-to-end test
|
||||
// spins up a minimal HTTP server on a port like :20191 (so the
|
||||
// :2019 substring matches fetchT's router and the URL parses as IPv6
|
||||
// literal), then proves that the helper forms the right Origin and
|
||||
// that an allowlist match produces 200.
|
||||
//
|
||||
// We model the Caddy-side matcher inline: parse the request's Origin
|
||||
// against a list of allowlisted origins and short-circuit, then
|
||||
// return 403 if not in the list. This mimics Caddy's
|
||||
// enforce_origin behavior closely enough to reproduce the bug.
|
||||
//
|
||||
// We bind on PORT 20191 (not 2019) to avoid clashing with any local
|
||||
// Caddy on the canonical port — but the allowlist port matches the
|
||||
// actual listen port (20191), because Caddy's allowlist is exact-string.
|
||||
// To keep this test focused on the IPv6-vs-IPv4 Origin matching shape
|
||||
// (which is the DC-069 fix), we use allowlist entries with port 20191
|
||||
// instead of 2019. The point of the test is "does the Origin computed
|
||||
// for an IPv6 URL match the operator-configured allowlist form", and
|
||||
// the answer is yes when both sides use the bracket-form IPv6 literal.
|
||||
const http = require('http');
|
||||
const allowlist = [
|
||||
'http://127.0.0.1:20191',
|
||||
// IPv6 — what DC-069 ADDS:
|
||||
'http://[::1]:20191',
|
||||
];
|
||||
|
||||
let capturedHeaders = null;
|
||||
let enforcedStatus = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
capturedHeaders = req.headers;
|
||||
const origin = req.headers.origin;
|
||||
if (!origin || !allowlist.includes(origin)) {
|
||||
enforcedStatus = 403;
|
||||
res.writeHead(403);
|
||||
res.end(`client is not allowed to access from origin "${origin}" (allowlist did not match)`);
|
||||
return;
|
||||
}
|
||||
enforcedStatus = 200;
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end('["::"]');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', (e) => {
|
||||
// On platforms without IPv6 (some CI sandboxes), the test will
|
||||
// fail to bind on `::1`. That's acceptable — DNS2 has IPv6.
|
||||
reject(e);
|
||||
});
|
||||
// Listen on IPv6 loopback so the URL routes over IPv6.
|
||||
server.listen(20191, '::1', resolve);
|
||||
});
|
||||
try {
|
||||
const { fetchT } = require('../src/utils/http');
|
||||
const result = await fetchT(
|
||||
'http://[::1]:20191/config/apps/http/servers/srv0/listen',
|
||||
{},
|
||||
5000
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
expect(enforcedStatus).toBe(200);
|
||||
expect(capturedHeaders.origin).toBe('http://[::1]:20191');
|
||||
// No sec-fetch-mode (raw http.request, no browser semantics)
|
||||
expect(capturedHeaders['sec-fetch-mode']).toBeUndefined();
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
|
||||
test('end-to-end probe on IPv6 loopback WITHOUT IPv6 origin in allowlist returns 403', async () => {
|
||||
// The bug, reproduced without the fix: same setup as above but with
|
||||
// an allowlist missing the IPv6 entry → 403. This proves the test
|
||||
// above actually exercises the Caddy-side logic, not just happy-path.
|
||||
const http = require('http');
|
||||
const allowlistMISSING = [
|
||||
'http://127.0.0.1:20192',
|
||||
// IPv6 entries INTENTIONALLY absent — this is the pre-fix state.
|
||||
];
|
||||
|
||||
let enforcedStatus = null;
|
||||
const server = http.createServer((req, res) => {
|
||||
const origin = req.headers.origin;
|
||||
if (!origin || !allowlistMISSING.includes(origin)) {
|
||||
enforcedStatus = 403;
|
||||
res.writeHead(403);
|
||||
res.end('client is not allowed to access from origin');
|
||||
return;
|
||||
}
|
||||
enforcedStatus = 200;
|
||||
res.writeHead(200);
|
||||
res.end('ok');
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(20192, '::1', resolve);
|
||||
});
|
||||
try {
|
||||
const { fetchT } = require('../src/utils/http');
|
||||
const result = await fetchT(
|
||||
'http://[::1]:20192/config/apps/http/servers/srv0/listen',
|
||||
{},
|
||||
5000
|
||||
);
|
||||
// Even though fetchT's request SUCCEEDS at the TCP level, the
|
||||
// mocked Caddy returns 403. The bug is in the allowlist.
|
||||
expect(result.status).toBe(403);
|
||||
expect(enforcedStatus).toBe(403);
|
||||
} finally {
|
||||
await new Promise((r) => server.close(r));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* DC-062: errorResponse arg-order regression test + caddy-upstreams JSON
|
||||
* response guarantees.
|
||||
*
|
||||
* Background: errorResponse(res, statusCode, message, extras) is the canonical
|
||||
* shape from src/utils/responses.js. Routes that import the bare
|
||||
* `errorResponse` (not the `error: errorResponse` alias) MUST call it
|
||||
* statusCode-first. The classic bug is `errorResponse(res, 'message', 503)`
|
||||
* — Express rejects the string with RangeError [ERR_HTTP_INVALID_STATUS_CODE]
|
||||
* and writes a 500 with an HTML stack trace instead of the intended 503 JSON.
|
||||
*
|
||||
* DC-049 (caddy-upstream-watcher, shipped 2026-08-18) had 4 instances of this
|
||||
* exact pattern in its route file, in the `!caddyUpstreamWatcher` defensive
|
||||
* branch. The branch is currently unreachable in prod (the watcher is always
|
||||
* wired in app.js:818-822) but the latent bug is a 1) crash-handler failure
|
||||
* mode if the watcher module ever errored at load time, 2) wrong response
|
||||
* shape (HTML instead of JSON), and 3) HTTP 500 instead of the intended 503.
|
||||
*
|
||||
* Two layers of fix:
|
||||
* 1. routes/caddy-upstreams.js — swap the 4 callsites to (res, 503, msg).
|
||||
* 2. src/utils/responses.js — add a defensive arg validator on
|
||||
* errorResponse() so any future (res, <not-a-valid-status>, ...)
|
||||
* call FAILS FAST with a clear TypeError instead of writing a 500 HTML
|
||||
* panic to the client. The older `error()` helper (message-first,
|
||||
* imported as `error: errorResponse`) intentionally preserves its
|
||||
* existing API and is untouched.
|
||||
*
|
||||
* This test exercises both fixes.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
// Use the repo's deps so the test fails under exactly the same module
|
||||
// resolution as production code (otherwise symlink/path differences can
|
||||
// mask validator-install gaps).
|
||||
// __dirname = /opt/dashcaddy/dashcaddy-api/__tests__
|
||||
// __dirname/../src/utils/responses = the file under test
|
||||
const repoRoot = path.join(__dirname, '..');
|
||||
|
||||
const { errorResponse, error: legacyError } = require(path.join(repoRoot, 'src/utils/responses'));
|
||||
|
||||
function get(port, urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.get(`http://localhost:${port}${urlPath}`, (resp) => {
|
||||
let body = '';
|
||||
resp.on('data', (c) => { body += c; });
|
||||
resp.on('end', () => resolve({ status: resp.statusCode, headers: resp.headers, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('errorResponse canonical arg-order + type guard (DC-062)', () => {
|
||||
test('correct order — (res, 503, msg) returns 503 JSON', () => {
|
||||
const mockRes = {
|
||||
status(code) { mockRes._code = code; return this; },
|
||||
json(body) { mockRes._body = body; return this; },
|
||||
};
|
||||
errorResponse(mockRes, 503, 'Caddy upstream watcher not initialized');
|
||||
expect(mockRes._code).toBe(503);
|
||||
expect(mockRes._body).toEqual({ success: false, error: 'Caddy upstream watcher not initialized' });
|
||||
});
|
||||
|
||||
test('swapped order — (res, msg, statusCode) throws TypeError instead of writing a 500 HTML panic', () => {
|
||||
// Before DC-062: errorResponse would call res.status('string-msg'),
|
||||
// Express throws RangeError, error middleware catches it, writes 500 HTML.
|
||||
// After DC-062: errorResponse itself rejects the call with a clear
|
||||
// TypeError, naming the wrong arg.
|
||||
const mockRes = {
|
||||
status: () => mockRes,
|
||||
json: () => mockRes,
|
||||
};
|
||||
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
||||
.toThrow(TypeError);
|
||||
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
||||
.toThrow(/statusCode must be an integer HTTP status/);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['NaN', NaN],
|
||||
['Infinity', Infinity],
|
||||
['string "503"', '503'],
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['underflow 99', 99],
|
||||
['overflow 600', 600],
|
||||
['float 503.5', 503.5],
|
||||
['object', { code: 503 }],
|
||||
['array', [503]],
|
||||
])('rejects invalid statusCode %s', (_name, badStatus) => {
|
||||
const mockRes = {
|
||||
status: () => mockRes,
|
||||
json: () => mockRes,
|
||||
};
|
||||
expect(() => errorResponse(mockRes, badStatus, 'msg')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('rejects non-string message', () => {
|
||||
const mockRes = {
|
||||
status: () => mockRes,
|
||||
json: () => mockRes,
|
||||
};
|
||||
expect(() => errorResponse(mockRes, 503, 123)).toThrow(TypeError);
|
||||
expect(() => errorResponse(mockRes, 503, null)).toThrow(TypeError);
|
||||
expect(() => errorResponse(mockRes, 503, undefined)).toThrow(TypeError);
|
||||
expect(() => errorResponse(mockRes, 503, { msg: 'x' })).toThrow(TypeError);
|
||||
});
|
||||
|
||||
test('preserves correct callers (DC-086 extras.code propagation still works)', () => {
|
||||
const mockRes = {
|
||||
status: () => mockRes,
|
||||
json: (b) => { mockRes._lastBody = b; return mockRes; },
|
||||
};
|
||||
errorResponse(mockRes, 409, 'Conflict', { code: 'DC-CONF-1', extra: 'detail' });
|
||||
expect(mockRes._lastBody).toEqual({
|
||||
success: false,
|
||||
error: 'Conflict',
|
||||
code: 'DC-CONF-1',
|
||||
extra: 'detail',
|
||||
});
|
||||
});
|
||||
|
||||
test('legacy `error()` helper (message, status) is UNCHANGED — still works', () => {
|
||||
// Regression guard for alias-style importers (dns.js, services.js,
|
||||
// ssl-monitor.js, license.js, dependencies.js, errorlogs.js, etc.).
|
||||
// The legacy helper takes (res, message, statusCode) order. Make sure
|
||||
// the validator we added to `errorResponse` doesn't bleed into
|
||||
// `error()`.
|
||||
const mockRes = {
|
||||
status(code) { mockRes._code = code; return this; },
|
||||
json(body) { mockRes._body = body; return this; },
|
||||
};
|
||||
legacyError(mockRes, 'service unavailable', 503);
|
||||
expect(mockRes._code).toBe(503);
|
||||
expect(mockRes._body).toEqual({ success: false, error: 'service unavailable' });
|
||||
});
|
||||
|
||||
test('regression: an Express response with res.status(string) emits HTML 500 — proves the bug pre-fix', async () => {
|
||||
// This is the failure mode DC-062 prevents. We still need this to
|
||||
// be true to prove the guard's value: if a call site ever slipped past
|
||||
// the validator (e.g. by sending a non-number disguised as code 0),
|
||||
// the server still doesn't return the intended status as JSON.
|
||||
const server = await new Promise((resolve) => {
|
||||
const app = express();
|
||||
app.get('/probe', (req, res) => {
|
||||
try {
|
||||
res.status('not a status').json({ ok: false });
|
||||
} catch (_) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
const s = app.listen(0, () => resolve({
|
||||
port: s.address().port,
|
||||
close: () => new Promise((r) => s.close(r)),
|
||||
}));
|
||||
});
|
||||
try {
|
||||
const resp = await get(server.port, '/probe');
|
||||
expect(resp.status).toBe(500);
|
||||
// Express renders an HTML error page (not JSON) — this is the bug
|
||||
// class DC-062 prevents at the helper layer.
|
||||
expect(resp.headers['content-type'] || '').toMatch(/text\/html/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mount the real route module and inject a null watcher — proves the
|
||||
// the four `!caddyUpstreamWatcher` paths now respond with the intended
|
||||
// 503 JSON shape, not a 500 HTML panic.
|
||||
describe('caddy-upstreams JSON response shape (route file literal fix)', () => {
|
||||
// The real route module exports a factory `function({ asyncHandler, caddyUpstreamWatcher, healthChecker })`.
|
||||
// We need to provide an asyncHandler shim since the route file uses it.
|
||||
function asyncHandlerShim(fn) { return fn; }
|
||||
// The factory also depends on the asyncHandler resolving rejected
|
||||
// promises to errors. Define a simple one that just calls next(err).
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
function mountRouter(router) {
|
||||
return new Promise((resolve) => {
|
||||
const app = express();
|
||||
app.use('/api/v1', router);
|
||||
const server = app.listen(0, () => resolve({
|
||||
port: server.address().port,
|
||||
close: () => new Promise((r) => server.close(r)),
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function loadRoute(deps) {
|
||||
return require(path.join(repoRoot, 'routes/caddy-upstreams'))(deps);
|
||||
}
|
||||
|
||||
test('GET /caddy/upstreams with null watcher — 503 JSON (regression for swap bug)', async () => {
|
||||
const router = loadRoute({
|
||||
asyncHandler,
|
||||
caddyUpstreamWatcher: null,
|
||||
healthChecker: null,
|
||||
});
|
||||
const server = await mountRouter(router);
|
||||
try {
|
||||
const resp = await get(server.port, '/api/v1/caddy/upstreams');
|
||||
expect(resp.status).toBe(503);
|
||||
expect(resp.body).toContain('"success":false');
|
||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /caddy/upstreams/:host/mute with null watcher — 503 JSON', async () => {
|
||||
const router = loadRoute({
|
||||
asyncHandler,
|
||||
caddyUpstreamWatcher: null,
|
||||
healthChecker: null,
|
||||
});
|
||||
const server = await mountRouter(router);
|
||||
try {
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: server.port,
|
||||
method: 'POST',
|
||||
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/mute',
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => {
|
||||
expect(res.statusCode).toBe(503);
|
||||
expect(body).toContain('"success":false');
|
||||
expect(body).toContain('Caddy upstream watcher not initialized');
|
||||
expect(res.headers['content-type'] || '').toMatch(/application\/json/);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => { throw e; });
|
||||
req.end();
|
||||
} finally {
|
||||
// server.close() will run via res.on('end') — defensively guard too.
|
||||
// (Don't double-close if test already returned.)
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /caddy/upstreams/mute (bare) with null watcher — 503 JSON', async () => {
|
||||
const router = loadRoute({
|
||||
asyncHandler,
|
||||
caddyUpstreamWatcher: null,
|
||||
healthChecker: null,
|
||||
});
|
||||
const server = await mountRouter(router);
|
||||
try {
|
||||
const resp = await new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: server.port,
|
||||
method: 'POST',
|
||||
path: '/api/v1/caddy/upstreams/mute',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end('{"host":"x","muted":true}');
|
||||
});
|
||||
expect(resp.status).toBe(503);
|
||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /caddy/upstreams/:host/unmute with null watcher — 503 JSON', async () => {
|
||||
const router = loadRoute({
|
||||
asyncHandler,
|
||||
caddyUpstreamWatcher: null,
|
||||
healthChecker: null,
|
||||
});
|
||||
const server = await mountRouter(router);
|
||||
try {
|
||||
const resp = await new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: 'localhost',
|
||||
port: server.port,
|
||||
method: 'POST',
|
||||
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/unmute',
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (c) => { body += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
expect(resp.status).toBe(503);
|
||||
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('route file source: no swapped-order patterns remain', () => {
|
||||
// Static scan of the post-fix route file: confirms the 4 swapped calls
|
||||
// are gone. If a future refactor re-introduces the pattern, this scan
|
||||
// catches it at test-time (before it ever lands in prod).
|
||||
const fs = require('fs');
|
||||
const src = fs.readFileSync(
|
||||
path.join(repoRoot, 'routes/caddy-upstreams.js'),
|
||||
'utf8'
|
||||
);
|
||||
// Match `errorResponse(res, <quote-or-backtick>, <int>)` — the
|
||||
// swapped-order shape (string literal in the 2nd arg position).
|
||||
const swappedRe = /errorResponse\(res,\s*['"`]/;
|
||||
expect(src).not.toMatch(swappedRe);
|
||||
// And confirm the corrected shape appears at least four times
|
||||
// (the four `!caddyUpstreamWatcher` guards).
|
||||
const canonicalRe = /errorResponse\(res,\s*503,\s*['"]Caddy upstream watcher not initialized['"]/g;
|
||||
const matches = src.match(canonicalRe) || [];
|
||||
expect(matches.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
/**
|
||||
* DC-076: Tests for the dashboard WebSocket server
|
||||
* DC-076 / DC-061: Tests for the dashboard WebSocket server
|
||||
*
|
||||
* DC-061 added:
|
||||
* - Real authVerifier injection (no string-presence-only check)
|
||||
* - Rejection of bare cookies / token query params
|
||||
* - close() detaches only OUR listeners (not shared SSE listeners)
|
||||
* - Message size cap (16 KB)
|
||||
* - parseCookieHeader unit coverage
|
||||
*/
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const EventEmitter = require('events');
|
||||
const createDashboardWS = require('../../src/websocket/dashboard-ws');
|
||||
const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');
|
||||
|
||||
function createMockServer() {
|
||||
return http.createServer((req, res) => {
|
||||
@@ -13,23 +20,38 @@ function createMockServer() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub verifier that mimics the production `session.isValid`
|
||||
* shape: takes an IncomingMessage-ish request, returns true iff the
|
||||
* session cookie value is a non-empty string.
|
||||
*/
|
||||
function cookieValueVerifier() {
|
||||
return (req) => {
|
||||
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
|
||||
const raw = parsed.dashcaddy_session;
|
||||
return typeof raw === 'string' && raw.length > 0;
|
||||
};
|
||||
}
|
||||
|
||||
describe('DC-076: Dashboard WebSocket', () => {
|
||||
let server, wsServer, port;
|
||||
let resourceMonitor, healthChecker, updateManager;
|
||||
|
||||
beforeEach((done) => {
|
||||
server = createMockServer();
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
|
||||
const resourceMonitor = new EventEmitter();
|
||||
const healthChecker = new EventEmitter();
|
||||
const updateManager = new EventEmitter();
|
||||
resourceMonitor = new EventEmitter();
|
||||
healthChecker = new EventEmitter();
|
||||
updateManager = new EventEmitter();
|
||||
|
||||
wsServer = createDashboardWS(server, {
|
||||
resourceMonitor,
|
||||
healthChecker,
|
||||
updateManager,
|
||||
log: { info: jest.fn(), error: jest.fn() },
|
||||
authVerifier: cookieValueVerifier(),
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
done();
|
||||
});
|
||||
@@ -40,19 +62,19 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
it('accepts connections at the upgrade path', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
ws.on('open', () => {
|
||||
ws.close();
|
||||
});
|
||||
ws.on('close', () => {
|
||||
done();
|
||||
it('accepts connections at the upgrade path with a session cookie', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('open', () => ws.close());
|
||||
ws.on('close', () => done());
|
||||
ws.on('error', done);
|
||||
});
|
||||
|
||||
it('sends a connected event on join', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('message', (raw) => {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'connected') {
|
||||
@@ -65,7 +87,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
});
|
||||
|
||||
it('responds to ping with pong', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
});
|
||||
@@ -80,7 +104,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
});
|
||||
|
||||
it('responds to subscribe with subscribed confirmation', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
||||
});
|
||||
@@ -96,7 +122,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
});
|
||||
|
||||
it('responds to client-count request', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ type: 'client-count' }));
|
||||
});
|
||||
@@ -112,7 +140,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
});
|
||||
|
||||
it('returns error for invalid JSON', (done) => {
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||
});
|
||||
ws.on('open', () => {
|
||||
ws.send('not json');
|
||||
});
|
||||
@@ -135,3 +165,210 @@ describe('DC-076: Dashboard WebSocket', () => {
|
||||
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DC-061 auth gate tests
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DC-061: WS upgrade auth gate', () => {
|
||||
let server, wsServer, port;
|
||||
|
||||
beforeEach((done) => {
|
||||
server = createMockServer();
|
||||
server.listen(0, () => {
|
||||
port = server.address().port;
|
||||
wsServer = createDashboardWS(server, {
|
||||
resourceMonitor: new EventEmitter(),
|
||||
healthChecker: new EventEmitter(),
|
||||
updateManager: new EventEmitter(),
|
||||
authVerifier: cookieValueVerifier(),
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
afterEach((done) => {
|
||||
wsServer.close();
|
||||
server.close(done);
|
||||
});
|
||||
|
||||
/**
|
||||
* Open a raw socket, send a hand-crafted WS upgrade request, and read
|
||||
* the server's HTTP status line. Avoids the ws library's auto-retry
|
||||
* behaviour so we get a deterministic single response.
|
||||
*/
|
||||
function probeUpgrade({ path, cookie, token } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const net = require('net');
|
||||
const sock = net.createConnection(port, '127.0.0.1');
|
||||
let buf = '';
|
||||
const headers = [
|
||||
`GET ${path || '/api/v1/ws'} HTTP/1.1`,
|
||||
'Host: 127.0.0.1',
|
||||
'Upgrade: websocket',
|
||||
'Connection: Upgrade',
|
||||
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
|
||||
'Sec-WebSocket-Version: 13',
|
||||
];
|
||||
if (cookie) headers.push(`Cookie: ${cookie}`);
|
||||
if (token) {
|
||||
const sep = path && path.includes('?') ? '&' : '?';
|
||||
headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
|
||||
}
|
||||
sock.on('connect', () => {
|
||||
sock.write(headers.join('\r\n') + '\r\n\r\n');
|
||||
});
|
||||
sock.on('data', (chunk) => {
|
||||
buf += chunk.toString('utf8');
|
||||
if (buf.includes('\r\n\r\n')) {
|
||||
sock.destroy();
|
||||
const statusLine = buf.split('\r\n')[0];
|
||||
const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
|
||||
resolve({ status, raw: buf });
|
||||
}
|
||||
});
|
||||
sock.on('error', (err) => {
|
||||
// Connection reset is fine — server destroys socket after 401.
|
||||
if (buf) resolve({ status: -1, raw: buf });
|
||||
else reject(err);
|
||||
});
|
||||
setTimeout(() => {
|
||||
if (!buf) {
|
||||
sock.destroy();
|
||||
reject(new Error('No response within 1s'));
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects WS upgrade with NO cookie', async () => {
|
||||
const res = await probeUpgrade({});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects WS upgrade with empty session cookie value', async () => {
|
||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
|
||||
const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
|
||||
// Pre-DC-061: any 11+ char token in ?token=... granted WS access in
|
||||
// production. Post-fix: token query param is ignored entirely; only a
|
||||
// valid session cookie grants access.
|
||||
const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
|
||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts upgrade when verifier returns true', async () => {
|
||||
const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
|
||||
// 101 Switching Protocols for successful WS handshake
|
||||
expect(res.status).toBe(101);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DC-061 close() listener detach test (the SSE-poisoning regression)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DC-061: close() detaches only OUR listeners', () => {
|
||||
it('does NOT remove listeners attached by SSE route to shared emitters', () => {
|
||||
// Set up two "subscribers" on the same EventEmitter, simulating the
|
||||
// real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
|
||||
// and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
|
||||
// SAME resourceMonitor. Calling dashboard-ws.close() must remove
|
||||
// ONLY wsHandler — sseHandler must remain.
|
||||
const server = createMockServer();
|
||||
const resourceMonitor = new EventEmitter();
|
||||
|
||||
// Pre-existing "SSE" listener (registered before dashboard-ws boots)
|
||||
const sseHandler = jest.fn();
|
||||
resourceMonitor.on('alert', sseHandler);
|
||||
|
||||
const wsServer = createDashboardWS(server, {
|
||||
resourceMonitor,
|
||||
healthChecker: new EventEmitter(),
|
||||
updateManager: new EventEmitter(),
|
||||
authVerifier: () => true,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
|
||||
// dashboard-ws added its own listener — verify it's there
|
||||
const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
|
||||
expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler
|
||||
|
||||
// Now close dashboard-ws — must not remove sseHandler
|
||||
wsServer.close();
|
||||
|
||||
const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
|
||||
expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone
|
||||
|
||||
// Confirm the surviving listener is the SSE one
|
||||
resourceMonitor.emit('alert', { test: true });
|
||||
expect(sseHandler).toHaveBeenCalledWith({ test: true });
|
||||
|
||||
server.close();
|
||||
});
|
||||
|
||||
it('is safe to call close() multiple times', () => {
|
||||
const server = createMockServer();
|
||||
const wsServer = createDashboardWS(server, {
|
||||
resourceMonitor: new EventEmitter(),
|
||||
healthChecker: new EventEmitter(),
|
||||
updateManager: new EventEmitter(),
|
||||
authVerifier: () => true,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
expect(() => {
|
||||
wsServer.close();
|
||||
wsServer.close();
|
||||
wsServer.close();
|
||||
}).not.toThrow();
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// DC-061 parseCookieHeader unit tests
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('parseCookieHeader', () => {
|
||||
it('returns empty object for undefined', () => {
|
||||
expect(parseCookieHeader(undefined)).toEqual({});
|
||||
});
|
||||
it('returns empty object for empty string', () => {
|
||||
expect(parseCookieHeader('')).toEqual({});
|
||||
});
|
||||
it('parses a single cookie pair', () => {
|
||||
expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
|
||||
});
|
||||
it('parses multiple cookie pairs', () => {
|
||||
expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
|
||||
});
|
||||
it('trims whitespace around names and values', () => {
|
||||
expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
|
||||
});
|
||||
it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
|
||||
// dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
|
||||
// must NOT url-decode (the HMAC verifier reads the raw value).
|
||||
expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
|
||||
dashcaddy_session: 'abc.def_123-XYZ',
|
||||
});
|
||||
});
|
||||
it('skips malformed pairs without `=`', () => {
|
||||
expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
|
||||
});
|
||||
it('skips empty name parts', () => {
|
||||
expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Exposes:
|
||||
* GET /api/v1/caddy/upstreams — full snapshot
|
||||
* GET /api/v1/caddy/upstreams/incidents — open dead-upstream incidents (via healthChecker)
|
||||
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } (also via query ?muted=true)
|
||||
* POST /api/v1/caddy/upstreams/mute — body { host, muted: true|false }
|
||||
* POST /api/v1/caddy/upstreams/:host/mute — body { muted: true|false } OR query ?muted=true
|
||||
* POST /api/v1/caddy/upstreams/:host/unmute — clears the mute
|
||||
*
|
||||
* Auth: same as the rest of /api/v1 — handled by the global middleware
|
||||
* (the router is mounted under the auth-gated apiRouter in app.js).
|
||||
@@ -16,12 +18,61 @@ const express = require('express');
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* DC-073: shared mute helper — used by all three mute endpoints so the
|
||||
* host-validation logic can't drift.
|
||||
*
|
||||
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
||||
* rejected unknown hosts (with a "not a known upstream" 400). The
|
||||
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
||||
* check entirely, so an authenticated operator could POST
|
||||
* `/caddy/upstreams/phantom.test:12345/mute` and the watcher would
|
||||
* silently add `phantom.test:12345` to its muted Set and `_saveState()`
|
||||
* would persist it to disk. The phantom entry then survives container
|
||||
* restarts, pollutes the snapshot view (the muted Set is iterated in
|
||||
* places like the dashboard's "muted upstreams" badge), and would
|
||||
* silently disable any future probe that happened to resolve to the
|
||||
* same string.
|
||||
*
|
||||
* Post-fix, every mute path runs through this helper so:
|
||||
* (1) host format is well-formed (rejects injection / `:` / `?` / etc.)
|
||||
* (2) host is in `caddyUpstreamWatcher.upstreams` (the live registry
|
||||
* populated by `scanSites()` reading every `reverse_proxy` from
|
||||
* /etc/caddy/sites/*. A phantom host cannot reach setMuted.)
|
||||
* (3) the muted Set never holds entries the scanner doesn't know.
|
||||
*
|
||||
* @param {Object} watcher caddyUpstreamWatcher instance
|
||||
* @param {string} host raw host string from the request
|
||||
* @param {boolean} wantMuted true to mute, false to unmute
|
||||
* @returns {{host: string, muted: boolean}} the result of setMuted
|
||||
* @throws {ValidationError} on invalid format or unknown host
|
||||
*/
|
||||
function validateAndMuteHost(watcher, host, wantMuted) {
|
||||
if (typeof host !== 'string' || host.length === 0 || host.length > 253) {
|
||||
throw new ValidationError('host must be a non-empty string up to 253 chars');
|
||||
}
|
||||
if (!/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
if (!watcher || !watcher.upstreams || !watcher.upstreams.has(host)) {
|
||||
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
||||
}
|
||||
return watcher.setMuted(host, wantMuted);
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker }) {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
// DC-062: errorResponse(res, statusCode, message) — statusCode-first per
|
||||
// src/utils/responses.js:66. The prior (res, message, statusCode) call
|
||||
// order passed a STRING as the status code, which made
|
||||
// res.status('Caddy upstream watcher not initialized') throw
|
||||
// RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express turning it into a
|
||||
// 500 with an HTML stack trace). All four `!caddyUpstreamWatcher`
|
||||
// guards had the same latent bug — fixed to canonical order.
|
||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||
}
|
||||
success(res, caddyUpstreamWatcher.snapshot());
|
||||
}, 'caddy-upstreams-list'));
|
||||
@@ -48,62 +99,48 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
||||
success(res, { incidents: open });
|
||||
}, 'caddy-upstreams-incidents'));
|
||||
|
||||
// POST /caddy/upstreams/mute body { host, muted }
|
||||
// POST /caddy/upstreams/:host/mute body { muted: true } OR query ?muted=true
|
||||
// Both shapes supported because the dashboard code is small and either is
|
||||
// ergonomic depending on caller.
|
||||
const handleMute = asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
}
|
||||
const host = req.params.host || req.body?.host;
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Accept muted as boolean body field OR ?muted=true|false query OR
|
||||
// a { muted: true|false } JSON body. Default to toggling on bare POST
|
||||
// without a muted value (this is the "mute it" path).
|
||||
let muted;
|
||||
if (typeof req.body?.muted === 'boolean') muted = req.body.muted;
|
||||
else if (typeof req.query.muted === 'string') muted = req.query.muted === 'true';
|
||||
else muted = true; // POST with no body = mute
|
||||
|
||||
const result = caddyUpstreamWatcher.setMuted(host, muted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute');
|
||||
|
||||
// Bare /mute with JSON body {host, muted}. Default mutes when muted is
|
||||
// absent or unparseable; require muted === false explicitly to unmute.
|
||||
// DC-073: now routes through validateAndMuteHost so the unknown-host
|
||||
// check applies (was already correct here pre-fix, but path-style
|
||||
// was missing it — see validateAndMuteHost docblock).
|
||||
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||
}
|
||||
const { host, muted } = req.body || {};
|
||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
// Explicit boolean coercion — string 'false' should NOT mute.
|
||||
const wantMuted = muted === undefined ? true : muted === true;
|
||||
if (caddyUpstreamWatcher.upstreams && !caddyUpstreamWatcher.upstreams.has(host)) {
|
||||
throw new ValidationError(`host ${host} is not a known upstream (run scan first)`);
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, wantMuted);
|
||||
const result = validateAndMuteHost(caddyUpstreamWatcher, host, wantMuted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute-bare'));
|
||||
|
||||
// /:host/mute and /:host/unmute for path-style toggles
|
||||
router.post('/caddy/upstreams/:host/mute', handleMute);
|
||||
// Path-style /:host/mute — body { muted: true|false } OR query ?muted=true|false.
|
||||
// DC-073: now also rejects unknown hosts (was the bug — see docblock).
|
||||
router.post('/caddy/upstreams/:host/mute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||
}
|
||||
let wantMuted;
|
||||
if (typeof req.body?.muted === 'boolean') wantMuted = req.body.muted;
|
||||
else if (typeof req.query.muted === 'string') wantMuted = req.query.muted === 'true';
|
||||
else wantMuted = true; // bare POST = mute
|
||||
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, wantMuted);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-mute'));
|
||||
|
||||
// DC-073: path-style /:host/unmute now also rejects unknown hosts.
|
||||
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
||||
if (!caddyUpstreamWatcher) {
|
||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
||||
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||
}
|
||||
const host = req.params.host;
|
||||
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||
throw new ValidationError('host must be a valid host[:port] string');
|
||||
}
|
||||
const result = caddyUpstreamWatcher.setMuted(host, false);
|
||||
const result = validateAndMuteHost(caddyUpstreamWatcher, req.params.host, false);
|
||||
success(res, result);
|
||||
}, 'caddy-upstreams-unmute'));
|
||||
|
||||
return router;
|
||||
};
|
||||
};
|
||||
|
||||
// Export the helper for unit tests so the validation surface can be
|
||||
// exercised without spinning up a full Express app.
|
||||
module.exports.__test = { validateAndMuteHost };
|
||||
@@ -11,10 +11,138 @@
|
||||
*/
|
||||
const express = require('express');
|
||||
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.
|
||||
* @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
|
||||
*/
|
||||
function generateSiteBlock(config) {
|
||||
@@ -38,12 +166,15 @@ function generateSiteBlock(config) {
|
||||
const lines = [];
|
||||
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') {
|
||||
lines.push(` tls internal`);
|
||||
} else if (tls === 'auto') {
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
@@ -52,7 +183,8 @@ function generateSiteBlock(config) {
|
||||
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) {
|
||||
lines.push(` import dashcaddy_auth ${authService}`);
|
||||
}
|
||||
@@ -66,16 +198,17 @@ function generateSiteBlock(config) {
|
||||
lines.push(` }`);
|
||||
}
|
||||
|
||||
// Custom headers
|
||||
if (Object.keys(headers).length > 0) {
|
||||
// Custom headers — keys validated against /^[A-Za-z0-9-]+$/, values
|
||||
// escaped via escapeCaddyQuotedString before being placed inside "..."
|
||||
if (headers && typeof headers === 'object' && Object.keys(headers).length > 0) {
|
||||
lines.push(` header {`);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
lines.push(` ${key} "${value}"`);
|
||||
lines.push(` ${key} "${escapeCaddyQuotedString(value)}"`);
|
||||
}
|
||||
lines.push(` }`);
|
||||
}
|
||||
|
||||
// Strip prefix
|
||||
// Strip prefix — validated to /^\/[A-Za-z0-9._\-/]*$/ — safe.
|
||||
if (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)');
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const caddyfile = generateSiteBlock(config);
|
||||
ok(res, { caddyfile, config });
|
||||
@@ -225,3 +371,11 @@ module.exports = function({ asyncHandler }) {
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// DC-070: export helpers for unit-testing the sanitization surface
|
||||
// independently of the route handler.
|
||||
module.exports.__test = {
|
||||
validateGenerationConfig,
|
||||
escapeCaddyQuotedString,
|
||||
generateSiteBlock,
|
||||
};
|
||||
|
||||
@@ -8,12 +8,17 @@
|
||||
* 3. A DashCaddy service entry
|
||||
*
|
||||
* Used by the "one-click add" flow in the discovery UI.
|
||||
*
|
||||
* DC-064: Caddy admin API safety — uses `fetchT` (with Origin + CSRF cookie
|
||||
* plumbing via http.js) instead of raw `fetch`, and resolves the admin URL
|
||||
* from the injected `caddy` context's `adminUrl` (which itself falls back to
|
||||
* `process.env.CADDY_ADMIN_URL`) instead of a hardcoded `localhost:2019`.
|
||||
*/
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
|
||||
module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler }) {
|
||||
module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig, asyncHandler, fetchT }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
@@ -65,7 +70,15 @@ module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig
|
||||
const tld = siteConfig?.tld || '.sami';
|
||||
const domain = `${serviceId}${tld}`;
|
||||
const upstreamHost = protocol === 'https' ? 'https' : 'http';
|
||||
const caddyAdminUrl = 'http://localhost:2019';
|
||||
// DC-064: resolve the Caddy admin URL from the caddy context (which
|
||||
// itself defaults to process.env.CADDY_ADMIN_URL via context/caddy.js).
|
||||
// Never hardcode localhost:2019 — non-loopback Caddy binds disable
|
||||
// enforce_origin and the raw fetch below would 403. Using fetchT (when
|
||||
// provided) includes the Origin header that satisfies enforce_origin;
|
||||
// when fetchT is null we fall back to raw fetch but ONLY for tests that
|
||||
// explicitly mock the admin URL.
|
||||
const caddyAdminUrl = caddy?.adminUrl || process.env.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const httpClient = typeof fetchT === 'function' ? fetchT : fetch;
|
||||
|
||||
const result = {
|
||||
service: null,
|
||||
@@ -119,8 +132,8 @@ module.exports = function({ docker, servicesStateManager, caddy, dns, siteConfig
|
||||
terminal: true,
|
||||
};
|
||||
|
||||
// Add via Caddy admin API
|
||||
const response = await fetch(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, {
|
||||
// Add via Caddy admin API (via fetchT so Origin header is present)
|
||||
const response = await httpClient(`${caddyAdminUrl}/config/apps/http/servers/srv0/routes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(routeConfig),
|
||||
|
||||
@@ -4,6 +4,50 @@ 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
|
||||
@@ -21,8 +65,8 @@ module.exports = function attachExecWS(server, log, authManager) {
|
||||
|
||||
const containerId = decodeURIComponent(match[1]);
|
||||
|
||||
// Validate container ID format to prevent injection
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerId)) {
|
||||
// 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();
|
||||
@@ -55,6 +99,35 @@ module.exports = function attachExecWS(server, log, authManager) {
|
||||
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);
|
||||
@@ -67,6 +140,7 @@ module.exports = function attachExecWS(server, log, authManager) {
|
||||
async function handleExec(ws, containerId, log, auth) {
|
||||
let execStream = null;
|
||||
let execInstance = null;
|
||||
const sessionStart = Date.now();
|
||||
|
||||
try {
|
||||
const container = docker.getContainer(containerId);
|
||||
@@ -78,10 +152,13 @@ async function handleExec(ws, containerId, log, auth) {
|
||||
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
|
||||
authId: auth.type === 'jwt' ? auth.userId : auth.keyId,
|
||||
containerName: info.Name,
|
||||
});
|
||||
|
||||
// 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', () => {
|
||||
// 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();
|
||||
@@ -148,6 +246,11 @@ async function handleExec(ws, containerId, log, auth) {
|
||||
});
|
||||
|
||||
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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+224
-54
@@ -12,6 +12,29 @@
|
||||
* POST /api/v1/fleet/deploy — deploy to multiple hosts
|
||||
*
|
||||
* Host state is persisted in {dataDir}/fleet-hosts.json
|
||||
*
|
||||
* Security (SSRF hardening, DC-068):
|
||||
* `POST /fleet/hosts` previously accepted any string as `hostname`, which
|
||||
* the subsequent `GET /fleet/status` flow composed verbatim into
|
||||
* `http://${hostname}:${port}/api/v1/system/health`. An authenticated
|
||||
* dashboard operator could register `hostname: "127.0.0.1"` or
|
||||
* `hostname: "169.254.169.254"` (cloud metadata service) and have the
|
||||
* container reach that internal endpoint on their behalf. The
|
||||
* `validateFleetHost()` + `resolveAndCheckAddress()` helpers in
|
||||
* `src/utilities/fleet-validation.js` close that hole:
|
||||
* - hostname syntax + port bounds + tag bounds (cheap, sync)
|
||||
* - literal IPv4/IPv6 private-range check (sync)
|
||||
* - DNS resolution + resolved-IP private-range check (async)
|
||||
* - Probe URL built from the RESOLVED IP, not the user-supplied
|
||||
* hostname, defeating DNS-rebinding attacks
|
||||
* - Probe concurrency capped at MAX_PROBE_CONCURRENCY so a malicious or
|
||||
* hung fleet can't stall the dashboard
|
||||
* - `FLEET_ALLOW_PRIVATE_HOSTS=true` opt-in for Tailscale / RFC1918
|
||||
* deployments where private hosts are intentional
|
||||
*
|
||||
* Hosts that violate validation are still surfaced in `GET /fleet/hosts`
|
||||
* (operator visibility), but `GET /fleet/status` skips them and tags them
|
||||
* `validation_failed` instead of probing.
|
||||
*/
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
@@ -20,13 +43,79 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { ErrorCodes } = require('../src/utilities/error-codes');
|
||||
const {
|
||||
validateFleetHost,
|
||||
resolveAndCheckAddress,
|
||||
} = require('../src/utilities/fleet-validation');
|
||||
|
||||
const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json');
|
||||
// Read lazily (per-request) so a test or operator script can flip the
|
||||
// opt-in at runtime without re-requiring the module.
|
||||
const ALLOW_PRIVATE_HOSTS = () => process.env.FLEET_ALLOW_PRIVATE_HOSTS === 'true';
|
||||
// Cap concurrent probes in /fleet/status — a malicious fleet with N hosts
|
||||
// would otherwise stall the dashboard with up to N parallel 3s timeouts.
|
||||
const MAX_PROBE_CONCURRENCY = 5;
|
||||
// Per-host probe timeout for /fleet/status.
|
||||
const PROBE_TIMEOUT_MS = 3000;
|
||||
|
||||
module.exports = function({ log, asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Re-validate every stored host's hostname+port (defense-in-depth against
|
||||
* a hand-edited fleet-hosts.json or an environment where validation
|
||||
* loosened since the entry was written). Returns the host with a
|
||||
* `validation` field describing current policy compliance.
|
||||
*/
|
||||
async function revalidateStoredHost(host, opts = {}) {
|
||||
const allowPrivate = !!opts.allowPrivate;
|
||||
const v = validateFleetHost({
|
||||
name: host.name,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
tags: host.tags,
|
||||
});
|
||||
if (!v.ok) {
|
||||
return { host, validation: { valid: false, code: v.code, message: v.message } };
|
||||
}
|
||||
// For DNS names, also resolve + check the resolved IP. Literal IPs are
|
||||
// already validated inside validateFleetHost(). Use `net.isIP` rather
|
||||
// than colon-presence heuristics so a real IPv6 with no dot is treated
|
||||
// as a literal (not as a DNS name), while URL-shaped strings like
|
||||
// `http://evil.com` (which contain both `:` and `/`) fall through to
|
||||
// the DNS-name path and get rejected by validateFleetHost()'s hostname
|
||||
// syntax check.
|
||||
const net = require('net');
|
||||
if (net.isIP(host.hostname) === 0) {
|
||||
const r = await resolveAndCheckAddress(host.hostname, { allowPrivate });
|
||||
if (!r.ok) {
|
||||
return { host, validation: { valid: false, code: r.code, message: r.message } };
|
||||
}
|
||||
return { host, validation: { valid: true, resolvedIp: r.ip, family: r.family } };
|
||||
}
|
||||
return { host, validation: { valid: true } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `worker(host)` over `hosts` with at most `MAX_PROBE_CONCURRENCY`
|
||||
* concurrent workers. Preserves order in the returned array so the
|
||||
* operator sees hosts in the same order they registered them.
|
||||
*/
|
||||
async function runWithConcurrency(hosts, worker, limit = MAX_PROBE_CONCURRENCY) {
|
||||
const out = new Array(hosts.length);
|
||||
let next = 0;
|
||||
const runners = Array.from({ length: Math.min(limit, hosts.length) }, () => (async () => {
|
||||
while (true) {
|
||||
const i = next++;
|
||||
if (i >= hosts.length) return;
|
||||
out[i] = await worker(hosts[i], i);
|
||||
}
|
||||
})());
|
||||
await Promise.all(runners);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadHosts() {
|
||||
const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
|
||||
try {
|
||||
@@ -50,45 +139,85 @@ module.exports = function({ log, asyncHandler }) {
|
||||
}));
|
||||
|
||||
// POST /api/v1/fleet/hosts — register a new host
|
||||
router.post('/fleet/hosts', wrap(async (req, res) => {
|
||||
const { name, hostname, apiKey, port = 3001, tags = [] } = req.body || {};
|
||||
router.post('/fleet/hosts', wrap(async (req, res) => {
|
||||
const body = req.body || {};
|
||||
const { apiKey, ...rest } = body;
|
||||
|
||||
if (!name || !hostname) {
|
||||
return errorResponse(res, 400, 'name and hostname are required', {
|
||||
code: ErrorCodes.GENERAL.INVALID_INPUT,
|
||||
});
|
||||
}
|
||||
// DC-068 SSRF hardening: synchronous structural validation first
|
||||
// (hostname syntax, port bounds, tag bounds, literal-IPv4 private range).
|
||||
// DNS rebinding protection runs after this via resolveAndCheckAddress().
|
||||
const v = validateFleetHost(rest);
|
||||
if (!v.ok) {
|
||||
const logDetail = { code: v.code, message: v.message };
|
||||
// Redact any user-supplied hostname in the audit log; only keep the
|
||||
// error code + length, never the raw value (it may be attacker-supplied
|
||||
// junk that has nothing to do with the real fleet).
|
||||
if (typeof body.hostname === 'string') logDetail.hostnameLen = body.hostname.length;
|
||||
if (log) log.warn('fleet', 'Host registration rejected by validation', logDetail);
|
||||
return errorResponse(res, 400, v.message, { code: v.code });
|
||||
}
|
||||
const { name, hostname, port, tags } = v.normalized;
|
||||
|
||||
const hosts = await loadHosts();
|
||||
// DC-068 DNS rebinding protection: if `hostname` is a DNS name (not a
|
||||
// literal IP), resolve it now and reject the registration if the resolved
|
||||
// address is private/reserved. The resolved IP is stored alongside the
|
||||
// hostname so /fleet/status probes it by IP, not by re-resolving the
|
||||
// name (closing the rebinding window). `net.isIP` distinguishes a real
|
||||
// IPv4 dotted-quad OR IPv6 from URL-shaped junk like `http://evil.com`
|
||||
// (which would otherwise be misclassified as IPv6 by a naive
|
||||
// colon-presence check).
|
||||
let resolvedIp = hostname;
|
||||
let dnsFamily = null;
|
||||
if (require('net').isIP(hostname) === 0) {
|
||||
const r = await resolveAndCheckAddress(hostname, { allowPrivate: ALLOW_PRIVATE_HOSTS() });
|
||||
if (!r.ok) {
|
||||
if (log) log.warn('fleet', 'Host registration rejected by DNS resolution', { code: r.code, message: r.message });
|
||||
return errorResponse(res, 400, r.message, { code: r.code });
|
||||
}
|
||||
resolvedIp = r.ip;
|
||||
dnsFamily = r.family;
|
||||
} else {
|
||||
// Literal IP — capture the IP family so /fleet/status and
|
||||
// /fleet/deploy can bracket-wrap IPv6 correctly when probes/URLs
|
||||
// are built from the resolved IP. resolvedIp stays equal to the
|
||||
// literal hostname so the existing test invariant still holds.
|
||||
dnsFamily = require('net').isIP(hostname);
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
if (hosts.some(h => h.hostname === hostname)) {
|
||||
return errorResponse(res, 409, `Host ${hostname} already registered`, {
|
||||
code: ErrorCodes.GENERAL.CONFLICT,
|
||||
});
|
||||
}
|
||||
const hosts = await loadHosts();
|
||||
|
||||
const host = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
apiKey: apiKey ? '***' : null, // Never store the actual key
|
||||
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
|
||||
tags,
|
||||
status: 'unknown',
|
||||
registeredAt: new Date().toISOString(),
|
||||
lastSeen: null,
|
||||
containerCount: null,
|
||||
};
|
||||
// Check for duplicate (compare on the original hostname string, not the
|
||||
// resolved IP — operators know their hosts by name).
|
||||
if (hosts.some(h => h.hostname === hostname)) {
|
||||
return errorResponse(res, 409, `Host ${hostname} already registered`, {
|
||||
code: ErrorCodes.GENERAL.CONFLICT,
|
||||
});
|
||||
}
|
||||
|
||||
hosts.push(host);
|
||||
await saveHosts(hosts);
|
||||
const host = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
hostname,
|
||||
port,
|
||||
tags,
|
||||
status: 'unknown',
|
||||
registeredAt: new Date().toISOString(),
|
||||
lastSeen: null,
|
||||
containerCount: null,
|
||||
// DNS rebinding protection — probe by this IP, not by re-resolving.
|
||||
resolvedIp,
|
||||
dnsFamily,
|
||||
apiKey: apiKey ? '***' : null, // Never store the actual key
|
||||
apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
|
||||
};
|
||||
|
||||
if (log) log.info('fleet', 'Host registered', { name, hostname });
|
||||
hosts.push(host);
|
||||
await saveHosts(hosts);
|
||||
|
||||
ok(res, { host }, 201);
|
||||
}));
|
||||
if (log) log.info('fleet', 'Host registered', { name, hostname, resolvedIp, dnsFamily });
|
||||
|
||||
ok(res, { host }, 201);
|
||||
}));
|
||||
|
||||
// DELETE /api/v1/fleet/hosts/:hostId
|
||||
router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => {
|
||||
@@ -105,20 +234,42 @@ module.exports = function({ log, asyncHandler }) {
|
||||
}));
|
||||
|
||||
// GET /api/v1/fleet/status — aggregate fleet status
|
||||
//
|
||||
// DC-068 SSRF hardening: every stored host is re-validated before probing
|
||||
// (defense-in-depth against a hand-edited fleet-hosts.json or a config
|
||||
// file written before this policy was enabled). Probes use the
|
||||
// `resolvedIp` captured at registration time — never re-resolve the
|
||||
// hostname, since DNS-rebinding attackers could flip the A record
|
||||
// between registration and probe. Probe concurrency is capped at
|
||||
// MAX_PROBE_CONCURRENCY so a malicious fleet with N hung hosts can't
|
||||
// stall the dashboard with up to N parallel timeouts.
|
||||
router.get('/fleet/status', wrap(async (req, res) => {
|
||||
const hosts = await loadHosts();
|
||||
|
||||
// Try to reach each host and get its health
|
||||
const statusPromises = hosts.map(async (host) => {
|
||||
// Validate all hosts (in parallel) and split into "probeable" vs
|
||||
// "validation_failed". Both lists are returned for operator visibility.
|
||||
const validated = await runWithConcurrency(
|
||||
hosts,
|
||||
(host) => revalidateStoredHost(host, { allowPrivate: ALLOW_PRIVATE_HOSTS() }),
|
||||
Math.max(MAX_PROBE_CONCURRENCY, hosts.length || 1)
|
||||
);
|
||||
|
||||
const probeTargets = validated.filter((v) => v.validation.valid);
|
||||
const skipped = validated
|
||||
.filter((v) => !v.validation.valid)
|
||||
.map((v) => ({ ...v.host, status: 'validation_failed', validationError: v.validation.message }));
|
||||
|
||||
const probeResults = await runWithConcurrency(probeTargets, async ({ host, validation }) => {
|
||||
const probeIp = validation.resolvedIp || host.hostname;
|
||||
const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
|
||||
const url = `http://${probeHost}:${host.port}/api/v1/system/health`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
try {
|
||||
const url = `http://${host.hostname}:${host.port}/api/v1/system/health`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {},
|
||||
}).finally(() => clearTimeout(timeout));
|
||||
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
host.status = data.status || 'healthy';
|
||||
@@ -129,25 +280,34 @@ module.exports = function({ log, asyncHandler }) {
|
||||
}
|
||||
} catch {
|
||||
host.status = 'offline';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
return host;
|
||||
});
|
||||
}, MAX_PROBE_CONCURRENCY);
|
||||
|
||||
const updatedHosts = await Promise.all(statusPromises);
|
||||
const updatedHosts = [...probeResults, ...skipped];
|
||||
await saveHosts(updatedHosts);
|
||||
|
||||
const summary = {
|
||||
total: updatedHosts.length,
|
||||
healthy: updatedHosts.filter(h => h.status === 'healthy').length,
|
||||
degraded: updatedHosts.filter(h => h.status === 'degraded').length,
|
||||
unhealthy: updatedHosts.filter(h => h.status === 'unhealthy').length,
|
||||
offline: updatedHosts.filter(h => h.status === 'offline' || h.status === 'unreachable').length,
|
||||
healthy: updatedHosts.filter((h) => h.status === 'healthy').length,
|
||||
degraded: updatedHosts.filter((h) => h.status === 'degraded').length,
|
||||
unhealthy: updatedHosts.filter((h) => h.status === 'unhealthy').length,
|
||||
offline: updatedHosts.filter((h) => h.status === 'offline' || h.status === 'unreachable').length,
|
||||
validation_failed: updatedHosts.filter((h) => h.status === 'validation_failed').length,
|
||||
};
|
||||
|
||||
ok(res, { summary, hosts: updatedHosts });
|
||||
}));
|
||||
|
||||
// POST /api/v1/fleet/deploy — deploy a template to multiple hosts
|
||||
//
|
||||
// DC-068 SSRF hardening: returns plan entries whose `deployUrl` is built
|
||||
// from `resolvedIp` (the address captured at registration time) — never
|
||||
// from the raw hostname. Operators copy-and-paste these URLs into the
|
||||
// forwarding tool of their choice; routing them through a literal IP
|
||||
// prevents a DNS-rebinding rename from pivoting the deploy call.
|
||||
router.post('/fleet/deploy', wrap(async (req, res) => {
|
||||
const { templateId, hostIds = [], config = {} } = req.body || {};
|
||||
|
||||
@@ -164,15 +324,25 @@ module.exports = function({ log, asyncHandler }) {
|
||||
return errorResponse(res, 400, 'No valid hosts to deploy to');
|
||||
}
|
||||
|
||||
// Generate deployment plan
|
||||
const plan = targetHosts.map(host => ({
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
templateId,
|
||||
config,
|
||||
status: 'pending',
|
||||
deployUrl: `http://${host.hostname}:${host.port}/api/v1/apps/deploy`,
|
||||
}));
|
||||
// Build the plan. Each entry's `deployUrl` is built from the host's
|
||||
// resolved IP (or the literal hostname for literal-IP hosts) — never
|
||||
// from a re-resolution of the raw hostname. IPv6 literals must be
|
||||
// wrapped in `[...]` so the URL parser preserves them as a single
|
||||
// authority. Use `net.isIP` against the resolved IP rather than the
|
||||
// stored `dnsFamily` so legacy entries (those registered before
|
||||
// dnsFamily was captured) still get correct bracket wrapping.
|
||||
const plan = targetHosts.map(host => {
|
||||
const probeIp = host.resolvedIp || host.hostname;
|
||||
const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
|
||||
return {
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
templateId,
|
||||
config,
|
||||
status: 'pending',
|
||||
deployUrl: `http://${probeHost}:${host.port}/api/v1/apps/deploy`,
|
||||
};
|
||||
});
|
||||
|
||||
ok(res, {
|
||||
templateId,
|
||||
|
||||
@@ -76,39 +76,261 @@ module.exports = function openClawRoutes(ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DC-065: OpenClaw proxy hardening.
|
||||
*
|
||||
* Three attack vectors were previously open:
|
||||
* (a) Unbounded response passthrough — proxyRes.on('data') wrote every
|
||||
* byte to the client without a cap, allowing a compromised/buggy
|
||||
* OpenClaw container to push arbitrarily large payloads (DoS,
|
||||
* log-spam, memory pressure on the API container).
|
||||
* (b) Hop-by-hop / response-shaping headers forwarded verbatim — Node's
|
||||
* `res.set(proxyRes.headers)` copies Connection, Keep-Alive,
|
||||
* Transfer-Encoding, Upgrade, Proxy-Authenticate, Proxy-Authorization,
|
||||
* TE, Trailers, Set-Cookie, Content-Encoding, Content-Length, and
|
||||
* Server. Per RFC 7230 §6.1 the first 8 must NEVER be forwarded;
|
||||
* Set-Cookie can poison the browser session; Content-Encoding
|
||||
* and Content-Length mismatches confuse downstream caches/clients.
|
||||
* (c) `proxyRes.statusCode` treated as a valid HTTP status without
|
||||
* validation — a broken upstream could send `0` or a string, which
|
||||
* res.status() would either accept (silent corruption) or throw
|
||||
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express default
|
||||
* error handler returns HTML).
|
||||
* (d) `path` taken from req.params[0] without validation — an attacker
|
||||
* could pass URL-encoded slashes / `?` / `#` chars / absolute URLs
|
||||
* to redirect the proxy elsewhere on localhost.
|
||||
*
|
||||
* The five fixes below close (a)-(d) without changing the on-the-wire
|
||||
* shape of the proxy from a same-origin browser's perspective.
|
||||
*/
|
||||
// RFC 7230 §6.1 hop-by-hop headers that must NEVER be forwarded by a proxy.
|
||||
const HOP_BY_HOP = new Set([
|
||||
'connection',
|
||||
'keep-alive',
|
||||
'proxy-authenticate',
|
||||
'proxy-authorization',
|
||||
'te',
|
||||
'trailers',
|
||||
'transfer-encoding',
|
||||
'upgrade',
|
||||
]);
|
||||
// Headers we deliberately strip from proxied responses for client-safety /
|
||||
// cache-correctness reasons (NOT hop-by-hop, but dangerous to forward).
|
||||
// DC-065 round-1 GLM-5.3 finding: `location` MUST be stripped — a
|
||||
// 3xx response with `Location: http://evil.com/x` would be honored by
|
||||
// the same-origin browser because the proxy response is on
|
||||
// /openclaw/proxy/* (same-origin from the dashboard's perspective) and
|
||||
// the proxy didn't downgrade the status. This is a classic open-redirect
|
||||
// through proxy. We strip Location and let the browser stay put (or,
|
||||
// for clients that depend on redirect-following, they can retry the
|
||||
// upstream directly without our proxy in the path).
|
||||
// DC-065 round-2 GLM-5.3 finding: `refresh` and `www-authenticate` are
|
||||
// in the same class and were also leaking. `Refresh: 0; url=...` is
|
||||
// honored by a meaningful subset of browsers (older Chrome, Firefox,
|
||||
// Safari, mobile WebViews) as an open-redirect primitive. `WWW-
|
||||
// Authenticate: Basic realm=...` pops a native browser auth dialog on
|
||||
// the dashboard's origin (phishing/UX attack). Both stripped.
|
||||
const STRIPPED_RESPONSE_HEADERS = new Set([
|
||||
'set-cookie', // upstream browser poisoning
|
||||
'location', // round-1 GLM finding — open-redirect through proxy
|
||||
'refresh', // round-2 GLM finding — same-class open-redirect primitive
|
||||
'www-authenticate', // round-2 GLM finding — phishing via browser auth prompt
|
||||
'content-encoding', // we send raw bytes; mismatched encoding breaks clients
|
||||
'content-length', // node auto-computes; forwarding can desync with body
|
||||
'server', // upstream fingerprinting
|
||||
'x-powered-by', // upstream fingerprinting
|
||||
]);
|
||||
// 5 MiB is a generous cap for a chat / gateway UI; anything larger is
|
||||
// either a misconfigured upstream or an attack. Picked to match the
|
||||
// express.json({ limit }) default in src/utilities/middleware.js.
|
||||
const MAX_PROXY_RESPONSE_BYTES = 5 * 1024 * 1024;
|
||||
// Allowed chars in the downstream `path` segment: alphanumerics, `-`, `_`,
|
||||
// `.`, `~`, `/`, `?`, `&`, `=`, `:`, `@`, `+`, `,`, `;` (RFC 3986 pchar +
|
||||
// query/fragment separators). Anything else → 400.
|
||||
const ALLOWED_PATH_RE = /^[a-zA-Z0-9._~/?&=:@+,;%\-]*$/;
|
||||
// Maximum total `path` length (reasonable for a gateway UI endpoint).
|
||||
const MAX_PATH_LEN = 1024;
|
||||
|
||||
function sanitizeForwardedHeaders(rawHeaders) {
|
||||
const out = {};
|
||||
for (const name of Object.keys(rawHeaders || {})) {
|
||||
const lower = name.toLowerCase();
|
||||
if (HOP_BY_HOP.has(lower)) continue;
|
||||
if (STRIPPED_RESPONSE_HEADERS.has(lower)) continue;
|
||||
out[name] = rawHeaders[name];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function coerceUpstreamStatus(rawStatus) {
|
||||
// Status must be an integer in 100..599. Anything else → 502 (the proxy
|
||||
// failed to interpret the upstream response, which is exactly what 502
|
||||
// semantically means: bad gateway).
|
||||
if (
|
||||
typeof rawStatus !== 'number'
|
||||
|| !Number.isInteger(rawStatus)
|
||||
|| rawStatus < 100
|
||||
|| rawStatus > 599
|
||||
) {
|
||||
return 502;
|
||||
}
|
||||
return rawStatus;
|
||||
}
|
||||
|
||||
function validatePath(path) {
|
||||
if (typeof path !== 'string') return { ok: false, code: 400, msg: 'path must be a string' };
|
||||
if (path.length === 0) return { ok: false, code: 400, msg: 'path is empty' };
|
||||
if (path.length > MAX_PATH_LEN) return { ok: false, code: 414, msg: 'path too long' };
|
||||
// Reject absolute-URL injection (`://`), backslashes (Windows path-style
|
||||
// smuggling), CRLF (header injection on rare downstream), and any char
|
||||
// outside the RFC 3986 pchar/query/fragment set.
|
||||
if (/[\s\\]|:\/\//.test(path)) return { ok: false, code: 400, msg: 'path contains forbidden characters' };
|
||||
if (!ALLOWED_PATH_RE.test(path)) return { ok: false, code: 400, msg: 'path contains disallowed characters' };
|
||||
// Strip a single leading slash so we can rebuild as `${targetBase}/${path}`
|
||||
// idempotently (targetBase already has a trailing `:PORT` form).
|
||||
return { ok: true, normalized: path.replace(/^\/+/, '') };
|
||||
}
|
||||
|
||||
// DC-065: expose helpers via the router for direct unit testing. The
|
||||
// router is an Express Router; any property we add here stays private
|
||||
// to the module and is read by __tests__/routes/openclaw.proxy-hardening
|
||||
// .test.js without going through Express.
|
||||
router._dc065 = {
|
||||
HOP_BY_HOP,
|
||||
STRIPPED_RESPONSE_HEADERS,
|
||||
MAX_PROXY_RESPONSE_BYTES,
|
||||
ALLOWED_PATH_RE,
|
||||
MAX_PATH_LEN,
|
||||
sanitizeForwardedHeaders,
|
||||
coerceUpstreamStatus,
|
||||
validatePath,
|
||||
};
|
||||
|
||||
function proxyRequest(req, res, targetBase, path, token) {
|
||||
const pathCheck = validatePath(path);
|
||||
if (!pathCheck.ok) {
|
||||
return errorResponse(res, pathCheck.code, pathCheck.msg);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['X-Forwarded-For'] = req.ip;
|
||||
headers['X-Forwarded-Proto'] = req.protocol;
|
||||
|
||||
const url = targetBase + '/' + path;
|
||||
const url = targetBase + '/' + pathCheck.normalized;
|
||||
const method = req.method;
|
||||
|
||||
// Stream the upstream response through `res` with a byte-size cap. On
|
||||
// overrun we abort the proxyReq and reply with 502 Bad Gateway. The
|
||||
// accumulated bytes are tracked per-call; if MAX_PROXY_RESPONSE_BYTES
|
||||
// is exceeded, we close the upstream and tear down the client response.
|
||||
function pipeUpstream(proxyReq) {
|
||||
// Buffer-first response proxy: collect chunks in memory until either
|
||||
// the upstream finishes or MAX_PROXY_RESPONSE_BYTES is exceeded. Then
|
||||
// emit a single Express response with sanitized headers + the
|
||||
// buffered body, or a 502 if the cap fired. Two reasons for the
|
||||
// buffer-first approach:
|
||||
//
|
||||
// 1. Once res.status() is called and headers are flushed (which
|
||||
// happens on the first res.write), the status code is locked.
|
||||
// Streaming the body through res.write lets a malicious
|
||||
// upstream send 1 byte of 200 OK + N bytes of garbage; we can't
|
||||
// retroactively downgrade to 502. Buffering lets us inspect
|
||||
// the full response before committing to a status.
|
||||
//
|
||||
// 2. Synchronous status/header/body emission is cheaper than
|
||||
// backpressure-aware chunked writes for a proxy that
|
||||
// specifically serves JSON-RPC + small payloads (OpenClaw's
|
||||
// gateway chat API is not a streaming use case).
|
||||
//
|
||||
// Memory cost: MAX_PROXY_RESPONSE_BYTES per concurrent proxy
|
||||
// request. At 5 MiB and Node's default 1000 concurrent connections
|
||||
// (server.maxConnections defaults to Infinity), worst-case is ~5
|
||||
// GiB. We cap concurrency in start.sh via Node CLI flags; see
|
||||
// ulimit + --max-old-space-size settings.
|
||||
const chunks = [];
|
||||
let totalBytes = 0;
|
||||
let capped = false;
|
||||
let finishedEarly = false;
|
||||
proxyReq.on('response', function(proxyRes) {
|
||||
// Pre-check: if upstream claimed a Content-Length above the cap,
|
||||
// reject before consuming any body bytes. This is the common case
|
||||
// — most well-behaved upstreams declare length up-front.
|
||||
const declaredLength = parseInt(proxyRes.headers['content-length'], 10);
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_PROXY_RESPONSE_BYTES) {
|
||||
capped = true;
|
||||
proxyReq.destroy();
|
||||
return errorResponse(res, 502, '[DC-065] upstream Content-Length ' + declaredLength + ' exceeds ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
|
||||
}
|
||||
proxyRes.on('data', function(chunk) {
|
||||
if (capped || finishedEarly) return;
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > MAX_PROXY_RESPONSE_BYTES) {
|
||||
capped = true;
|
||||
proxyReq.destroy();
|
||||
if (!finishedEarly) {
|
||||
finishedEarly = true;
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
errorResponse(res, 502, '[DC-065] upstream response exceeded ' + MAX_PROXY_RESPONSE_BYTES + '-byte proxy cap');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
proxyRes.on('end', function() {
|
||||
if (capped) return;
|
||||
finishedEarly = true;
|
||||
const body = Buffer.concat(chunks);
|
||||
const safeHeaders = sanitizeForwardedHeaders(proxyRes.headers);
|
||||
try { res.set(safeHeaders); } catch (_) { /* noop if socket closed */ }
|
||||
const safeStatus = coerceUpstreamStatus(proxyRes.statusCode);
|
||||
try {
|
||||
res.status(safeStatus);
|
||||
res.end(body);
|
||||
} catch (_) { /* socket may be closed */ }
|
||||
});
|
||||
proxyRes.on('error', function() {
|
||||
if (!finishedEarly) {
|
||||
finishedEarly = true;
|
||||
try {
|
||||
if (!res.headersSent) res.status(502).end();
|
||||
else res.end();
|
||||
} catch (_) { /* socket may be closed */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
proxyReq.on('error', function(e) {
|
||||
if (!finishedEarly) {
|
||||
finishedEarly = true;
|
||||
if (!res.headersSent && !res.writableEnded) {
|
||||
errorResponse(res, 502, e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
proxyReq.setTimeout(15000, function() {
|
||||
proxyReq.destroy();
|
||||
if (!finishedEarly && !res.headersSent && !res.writableEnded) {
|
||||
finishedEarly = true;
|
||||
errorResponse(res, 504, 'gateway timeout');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = JSON.stringify(req.body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
|
||||
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
|
||||
const proxyReq = http.request(url, { method: method, headers: headers });
|
||||
pipeUpstream(proxyReq);
|
||||
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
|
||||
proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
} else {
|
||||
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { errorResponse(res, 502, e.message); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); errorResponse(res, 504, 'gateway timeout'); });
|
||||
const proxyReq = http.get(url, { headers: headers });
|
||||
pipeUpstream(proxyReq);
|
||||
proxyReq.on('error', function() { /* surface handled in pipeUpstream */ });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,11 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { ok, error: errorResponse } = require('../src/utils/responses');
|
||||
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
|
||||
// shape — alias `error: errorResponse` used here previously was message-first
|
||||
// which silently mis-called every callsite (15 endpoints surfaced as 500 HTML
|
||||
// panics instead of the intended 4xx JSON).
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { getStore } = require('../src/security/event-store');
|
||||
const { getRegistry } = require('../src/security/host-registry');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
@@ -10,7 +10,11 @@ const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
|
||||
// shape — alias `error: errorResponse` used here previously was message-first
|
||||
// which silently mis-called 3 credential-store callsites (returned 500 HTML
|
||||
// panics for invalid serviceId instead of the intended 400 JSON).
|
||||
const { success, errorResponse } = require('../src/utils/responses');
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
@@ -398,7 +402,8 @@ module.exports = function({
|
||||
try {
|
||||
validateServiceConfig({ id, name });
|
||||
} catch (validationErr) {
|
||||
return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors });
|
||||
// DC-063: canonical shape (res, statusCode, message, extras) per responses.js:76.
|
||||
return errorResponse(res, 400, validationErr.message, { errors: validationErr.errors });
|
||||
}
|
||||
|
||||
await servicesStateManager.update(services => {
|
||||
@@ -423,7 +428,8 @@ module.exports = function({
|
||||
} catch (error) {
|
||||
log.error('deploy', error, null, { note: 'Error adding service' });
|
||||
if (error.message.includes('already exists')) {
|
||||
errorResponse(res, safeErrorMessage(error), 409);
|
||||
// DC-063: canonical shape per responses.js:76.
|
||||
errorResponse(res, 409, safeErrorMessage(error));
|
||||
} else {
|
||||
// Error handled by middleware
|
||||
}
|
||||
@@ -445,7 +451,8 @@ module.exports = function({
|
||||
try {
|
||||
validateServiceConfig(service);
|
||||
} catch (validationErr) {
|
||||
return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors });
|
||||
// DC-063: canonical shape per responses.js:76.
|
||||
return errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,7 +482,8 @@ module.exports = function({
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return errorResponse(res, `Service "${id}" not found`, 404);
|
||||
// DC-063: canonical shape per responses.js:76.
|
||||
return errorResponse(res, 404, `Service "${id}" not found`);
|
||||
}
|
||||
|
||||
resyncHealthChecker?.().catch(() => {});
|
||||
|
||||
+11
-1
@@ -75,7 +75,16 @@ process.on('uncaughtException', (error) => {
|
||||
// .on() on a class threw on every boot and silently killed the WS).
|
||||
try {
|
||||
const { ctx } = app.locals;
|
||||
const createDashboardWS = require('./src/websocket/dashboard-ws');
|
||||
const createDashboardWS = require('./src/websocket/dashboard-ws').createDashboardWS;
|
||||
|
||||
// DC-061: WS upgrade bypasses Express middleware, so inject the
|
||||
// real session verifier from the shared context. Without this
|
||||
// the WS would fall back to a presence-only cookie check that
|
||||
// any attacker can satisfy by setting a cookie named
|
||||
// `dashcaddy_session` (verified HMAC required, not just name).
|
||||
const authVerifier = (ctx.session && typeof ctx.session.isValid === 'function')
|
||||
? ctx.session.isValid
|
||||
: null;
|
||||
|
||||
createDashboardWS(server, {
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
@@ -86,6 +95,7 @@ process.on('uncaughtException', (error) => {
|
||||
driftDetector: ctx.driftDetector,
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker,
|
||||
authVerifier,
|
||||
log,
|
||||
});
|
||||
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
|
||||
|
||||
@@ -634,6 +634,7 @@ async function createApp() {
|
||||
caddy: ctx.caddy,
|
||||
dns: ctx.dns,
|
||||
siteConfig: ctx.config,
|
||||
fetchT: ctx.fetchT,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ class AutoRestartManager extends EventEmitter {
|
||||
// Transition: healthy → unhealthy
|
||||
if (previousStatus === 'up' && currentStatus === 'down') {
|
||||
// Find the containerId from the health checker config or status details
|
||||
const containerId = this._resolveContainerId(serviceId, status);
|
||||
const containerId = await this._resolveContainerId(serviceId, status);
|
||||
if (containerId) {
|
||||
try {
|
||||
await this.handleContainerDown(serviceId, containerId);
|
||||
@@ -429,12 +429,19 @@ class AutoRestartManager extends EventEmitter {
|
||||
/**
|
||||
* Attempt to find the containerId for a service from various sources.
|
||||
*
|
||||
* DC-060: the previous implementation fired the async lookup via `.then(...)`
|
||||
* but discarded the returned containerId, returning `undefined` from the
|
||||
* function. Callers (`_handleStatusCheck`) gate on the return value, so
|
||||
* every auto-restart whose containerId came from servicesStateManager
|
||||
* silently no-op'd. Now awaits the read() promise so the containerId
|
||||
* actually propagates.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @param {Object} status - The status-check event data
|
||||
* @returns {string|null}
|
||||
* @returns {Promise<string|null>}
|
||||
* @private
|
||||
*/
|
||||
_resolveContainerId(serviceId, status) {
|
||||
async _resolveContainerId(serviceId, status) {
|
||||
// Check if it's in the status details (some health checks embed it)
|
||||
if (status.details?.containerId) return status.details.containerId;
|
||||
|
||||
@@ -442,23 +449,20 @@ class AutoRestartManager extends EventEmitter {
|
||||
const hcService = this.healthChecker?.config?.services?.[serviceId];
|
||||
if (hcService?.containerId) return hcService.containerId;
|
||||
|
||||
// Try to look it up from the services state manager
|
||||
// Try to look it up from the services state manager. StateManager.read()
|
||||
// is async (returns a Promise) — must await, not fire-and-forget.
|
||||
try {
|
||||
const servicesStateManager = this.ctx.servicesStateManager;
|
||||
if (servicesStateManager) {
|
||||
const readResult = servicesStateManager.read();
|
||||
if (readResult && typeof readResult.then === 'function') {
|
||||
// It returns a promise — fire-and-forget lookup
|
||||
readResult.then(list => {
|
||||
const found = (list || []).find(s => s.id === serviceId);
|
||||
return found?.containerId || null;
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
const found = (readResult || []).find(s => s.id === serviceId);
|
||||
if (found?.containerId) return found.containerId;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
if (!servicesStateManager) return null;
|
||||
const list = await servicesStateManager.read();
|
||||
const found = (list || []).find(s => s.id === serviceId);
|
||||
if (found?.containerId) return found.containerId;
|
||||
} catch (err) {
|
||||
// Best-effort: a state-manager read failure must not break the bridge.
|
||||
// Surface at debug level so an operator hunting "why didn't auto-restart
|
||||
// fire?" can find it without polluting the info-level event stream.
|
||||
this.log?.debug?.('auto-restart', 'containerId resolve failed', { serviceId, error: err?.message });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Fleet-host input validation — defends against SSRF on /api/v1/fleet/*.
|
||||
*
|
||||
* Why this lives in its own module instead of inline in routes/fleet.js:
|
||||
* The fleet endpoints compose a user-supplied hostname + port into a URL
|
||||
* that is then fetched from inside the dashcaddy-api container
|
||||
* (DC-108, GET /fleet/status probes `http://${hostname}:${port}/api/v1/system/health`;
|
||||
* POST /fleet/deploy returns `http://${hostname}:${port}/api/v1/apps/deploy`
|
||||
* for the operator to call). Without validation, an authenticated dashboard
|
||||
* operator could register a host with `hostname: "127.0.0.1"` or
|
||||
* `hostname: "169.254.169.254"` (cloud metadata service) and have the
|
||||
* container reach that internal endpoint on the operator's behalf. Worse:
|
||||
* a hostname like `attacker.example.com` could exploit DNS rebinding
|
||||
* (public IP at registration time → loopback IP at fetch time).
|
||||
*
|
||||
* By extracting `validateFleetHost()`, `isPrivateOrReservedIPv4()`, and
|
||||
* `isPrivateOrReservedIPv6()` here, the policy is unit-testable without
|
||||
* booting Express + auth + CSRF, and a future route that wants the same
|
||||
* guard can reuse it.
|
||||
*
|
||||
* Default-deny posture:
|
||||
* - Reject IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16 —
|
||||
* including the AWS/GCP/Azure metadata address 169.254.169.254), RFC 1918
|
||||
* private (10/8, 172.16/12, 192.168/16), CGNAT (100.64.0.0/10,
|
||||
* which Tailscale uses), multicast (224.0.0.0/4), broadcast
|
||||
* (255.255.255.255), and the reserved/documentation ranges (0.0.0.0/8,
|
||||
* 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24,
|
||||
* 203.0.113.0/24, 240.0.0.0/4).
|
||||
* - Reject IPv6 loopback (::1), link-local (fe80::/10), ULA (fc00::/7),
|
||||
* multicast (ff00::/8), and the IPv4-mapped loopback (::ffff:127.0.0.1).
|
||||
* - Allow public DNS hostnames (e.g. `fleet.example.com`) and public IPs.
|
||||
* - To opt in to private-network hosts (a real fleet of homelab DashCaddy
|
||||
* instances behind Tailscale or RFC1918), set FLEET_ALLOW_PRIVATE_HOSTS=true
|
||||
* in the operator's environment. Even then, DNS-rebinding protection still
|
||||
* resolves the hostname once before probing and rejects private results.
|
||||
*
|
||||
* Public API:
|
||||
* validateFleetHost({ name, hostname, port, tags })
|
||||
* -> { ok: true, normalized: {...} } | { ok: false, code, message }
|
||||
* resolveAndCheckAddress(hostname)
|
||||
* -> { ok: true, ip } | { ok: false, code, message }
|
||||
* Resolves a DNS hostname to its first A/AAAA record and validates the
|
||||
* resolved IP is also non-private (defends against DNS rebinding).
|
||||
* isPrivateOrReservedIPv4(ip)
|
||||
* isPrivateOrReservedIPv6(ip)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dns = require('dns').promises;
|
||||
|
||||
// IPv4 ranges that should NEVER be probed from the fleet container unless
|
||||
// the operator has explicitly opted in via FLEET_ALLOW_PRIVATE_HOSTS.
|
||||
// Order matters: most specific (longest prefix) first so a `192.168.x.y`
|
||||
// check happens before a generic `192.*` swallow-all.
|
||||
const PRIVATE_OR_RESERVED_IPV4 = [
|
||||
// ── Broadcast — checked first because 255.255.255.255 matches the
|
||||
// `240.0.0.0/4 reserved` range and would otherwise be mislabeled.
|
||||
{ cidr: '255.255.255.255/32', label: 'broadcast' },
|
||||
// ── Loopback (RFC 1122) ──
|
||||
// 127.0.0.0/8 — covers 127.0.0.1 and the rest of the loopback block.
|
||||
{ cidr: '127.0.0.0/8', label: 'loopback (RFC 1122)' },
|
||||
// ── Link-local (RFC 3927) + cloud metadata ──
|
||||
// 169.254.0.0/16 covers AWS / GCP / Azure metadata at 169.254.169.254
|
||||
// (the canonical IMDS endpoint) and any other link-local address.
|
||||
{ cidr: '169.254.0.0/16', label: 'link-local / cloud-metadata (RFC 3927, IMDS)' },
|
||||
// ── RFC 1918 private ──
|
||||
{ cidr: '10.0.0.0/8', label: 'RFC 1918 private' },
|
||||
{ cidr: '172.16.0.0/12', label: 'RFC 1918 private' },
|
||||
{ cidr: '192.168.0.0/16', label: 'RFC 1918 private' },
|
||||
// ── CGNAT (RFC 6598) — Tailscale uses this range ──
|
||||
{ cidr: '100.64.0.0/10', label: 'CGNAT / Tailscale (RFC 6598)' },
|
||||
// ── Multicast (RFC 5771) ──
|
||||
{ cidr: '224.0.0.0/4', label: 'multicast (RFC 5771)' },
|
||||
// ── Reserved / documentation / benchmarks ──
|
||||
{ cidr: '0.0.0.0/8', label: 'reserved "this network" (RFC 1122)' },
|
||||
{ cidr: '192.0.0.0/24', label: 'IETF protocol assignments (RFC 6890)' },
|
||||
{ cidr: '192.0.2.0/24', label: 'TEST-NET-1 documentation (RFC 5737)' },
|
||||
{ cidr: '198.18.0.0/15', label: 'benchmark testing (RFC 2544)' },
|
||||
{ cidr: '198.51.100.0/24', label: 'TEST-NET-2 documentation (RFC 5737)' },
|
||||
{ cidr: '203.0.113.0/24', label: 'TEST-NET-3 documentation (RFC 5737)' },
|
||||
{ cidr: '240.0.0.0/4', label: 'reserved for future use (RFC 1112)' },
|
||||
];
|
||||
|
||||
/**
|
||||
* IPv4 reserved-range check. Returns { isPrivate, label } where label names
|
||||
* the matched range (loopback / RFC 1918 / etc.) for human-readable errors.
|
||||
*/
|
||||
function isPrivateOrReservedIPv4(ip) {
|
||||
if (typeof ip !== 'string') return { isPrivate: false, label: null };
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return { isPrivate: false, label: null };
|
||||
const nums = parts.map((p) => parseInt(p, 10));
|
||||
if (nums.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
|
||||
return { isPrivate: false, label: null };
|
||||
}
|
||||
// Decode the IP to a 32-bit unsigned integer for prefix matching.
|
||||
const asInt = ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
|
||||
for (const { cidr, label } of PRIVATE_OR_RESERVED_IPV4) {
|
||||
const [base, bits] = cidr.split('/');
|
||||
const prefix = parseInt(bits, 10);
|
||||
const baseParts = base.split('.').map((p) => parseInt(p, 10));
|
||||
const baseInt = ((baseParts[0] << 24) | (baseParts[1] << 16) | (baseParts[2] << 8) | baseParts[3]) >>> 0;
|
||||
// Build a mask by shifting prefix bits down from the top.
|
||||
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
|
||||
if ((asInt & mask) === (baseInt & mask)) {
|
||||
return { isPrivate: true, label };
|
||||
}
|
||||
}
|
||||
// Broadcast is now handled by the cidr list (255.255.255.255/32 entry),
|
||||
// checked first to win over the 240.0.0.0/4 reserved-for-future-use range.
|
||||
return { isPrivate: false, label: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* IPv6 reserved-range check. Returns { isPrivate, label }.
|
||||
*/
|
||||
function isPrivateOrReservedIPv6(ip) {
|
||||
if (typeof ip !== 'string') return { isPrivate: false, label: null };
|
||||
// Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1) -> delegate to v4 check.
|
||||
const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
|
||||
if (mapped) {
|
||||
const v4Check = isPrivateOrReservedIPv4(mapped[1]);
|
||||
return v4Check.isPrivate
|
||||
? { isPrivate: true, label: `IPv4-mapped (${mapped[1]})` }
|
||||
: { isPrivate: false, label: null };
|
||||
}
|
||||
const lc = ip.toLowerCase();
|
||||
// ::1 loopback
|
||||
if (lc === '::1') return { isPrivate: true, label: 'IPv6 loopback (RFC 4291)' };
|
||||
// :: unspecified
|
||||
if (lc === '::') return { isPrivate: true, label: 'IPv6 unspecified (RFC 4291)' };
|
||||
// fe80::/10 link-local
|
||||
if (/^fe[89ab][0-9a-f]:/i.test(lc) || /^fe80::/i.test(lc)) {
|
||||
return { isPrivate: true, label: 'IPv6 link-local (RFC 4291)' };
|
||||
}
|
||||
// fc00::/7 unique-local (ULA)
|
||||
if (/^[fF][cdCE]/.test(lc)) {
|
||||
return { isPrivate: true, label: 'IPv6 unique-local (RFC 4193)' };
|
||||
}
|
||||
// ff00::/8 multicast
|
||||
if (/^ff[0-9a-fA-F]?[0-9a-fA-F]?:/.test(lc)) {
|
||||
return { isPrivate: true, label: 'IPv6 multicast (RFC 4291)' };
|
||||
}
|
||||
return { isPrivate: false, label: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight hostname syntax check (RFC 1123-style DNS names + literal IPs).
|
||||
* `net.isIP` would also work for IP literals, but we accept IPv6 with
|
||||
* a leading colon here and delegate that branch separately.
|
||||
*/
|
||||
const RFC1123_LABEL = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
function isValidHostnameSyntax(hostname) {
|
||||
if (typeof hostname !== 'string') return false;
|
||||
if (hostname.length === 0 || hostname.length > 253) return false;
|
||||
// Trailing dot is legal (signals root); strip for label parsing.
|
||||
let h = hostname;
|
||||
if (h.endsWith('.')) h = h.slice(0, -1);
|
||||
if (h.length === 0) return false;
|
||||
const labels = h.split('.');
|
||||
if (labels.length === 0) return false;
|
||||
for (const label of labels) {
|
||||
if (!RFC1123_LABEL.test(label)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async DNS-resolve the hostname to its first A and AAAA records, run the
|
||||
* private-range check on each, and return the first non-private match. If
|
||||
* all resolved addresses are private (or the name doesn't resolve), report
|
||||
* the failure mode so the caller can return a meaningful 400.
|
||||
*
|
||||
* DNS-rebinding protection: by resolving ONCE at validation time and returning
|
||||
* the IP, a follow-up probe URL built from the resolved IP can't be pointed
|
||||
* at a different IP via a fast-flipping DNS record. For maximum robustness
|
||||
* the caller should pass the resolved IP back as the host's `resolvedIp` so
|
||||
* future `fetch()` calls use `http://<resolvedIp>:<port>`, not
|
||||
* `http://<hostname>:<port>`.
|
||||
*/
|
||||
async function resolveAndCheckAddress(hostname, opts = {}) {
|
||||
const allowPrivate = !!opts.allowPrivate;
|
||||
if (typeof hostname !== 'string' || hostname.length === 0) {
|
||||
return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname is required' };
|
||||
}
|
||||
// Literal IPv4 -- skip the DNS round-trip.
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
const v4Check = isPrivateOrReservedIPv4(hostname);
|
||||
if (v4Check.isPrivate && !allowPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV4',
|
||||
message: `hostname "${hostname}" resolves to a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
return { ok: true, ip: hostname, family: 4 };
|
||||
}
|
||||
// Literal IPv6 -- detect by containing a colon AND no `/` or `://`
|
||||
// substrings (URL-like strings contain colons but aren't IPv6). Use
|
||||
// Node's built-in `net.isIP` for the authoritative check; the
|
||||
// colon-presence check is a fast-path to skip the DNS call for obvious
|
||||
// IPv6 inputs.
|
||||
const net = require('net');
|
||||
const isLikelyIPv6 = hostname.includes(':') && net.isIP(hostname) === 6;
|
||||
if (isLikelyIPv6) {
|
||||
const v6Check = isPrivateOrReservedIPv6(hostname);
|
||||
if (v6Check.isPrivate && !allowPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV6',
|
||||
message: `hostname "${hostname}" resolves to a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
return { ok: true, ip: hostname, family: 6 };
|
||||
}
|
||||
// Hostname syntax guard before DNS call -- saves an OS query for obvious junk.
|
||||
if (!isValidHostnameSyntax(hostname)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_HOSTNAME',
|
||||
message: `hostname "${hostname}" is not a valid DNS name or IP address`,
|
||||
};
|
||||
}
|
||||
// DNS resolve.
|
||||
let results;
|
||||
try {
|
||||
results = await dns.lookup(hostname, { all: true });
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DNS_RESOLUTION_FAILED',
|
||||
message: `hostname "${hostname}" did not resolve: ${err.code || err.message}`,
|
||||
};
|
||||
}
|
||||
if (!results || results.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DNS_NO_RECORDS',
|
||||
message: `hostname "${hostname}" has no A or AAAA records`,
|
||||
};
|
||||
}
|
||||
for (const r of results) {
|
||||
if (r.family === 4) {
|
||||
const v4Check = isPrivateOrReservedIPv4(r.address);
|
||||
if (v4Check.isPrivate && !allowPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV4',
|
||||
message: `hostname "${hostname}" resolves to ${r.address}, a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
return { ok: true, ip: r.address, family: 4 };
|
||||
} else if (r.family === 6) {
|
||||
const v6Check = isPrivateOrReservedIPv6(r.address);
|
||||
if (v6Check.isPrivate && !allowPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV6',
|
||||
message: `hostname "${hostname}" resolves to ${r.address}, a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
return { ok: true, ip: r.address, family: 6 };
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
code: 'DNS_NO_RECORDS',
|
||||
message: `hostname "${hostname}" has no usable A or AAAA records`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the full input shape of POST /fleet/hosts and POST /fleet/deploy.
|
||||
* On success, returns the normalized payload (with `port` coerced to int and
|
||||
* `hostname` lowercased). On failure, returns { ok: false, code, message } for
|
||||
* the caller to surface as a 400 errorResponse.
|
||||
*
|
||||
* Validates in this order (cheapest predicate first):
|
||||
* 1. name: string, 1..100 chars, no control chars
|
||||
* 2. hostname: syntax (IP or RFC 1123 DNS name); literal IPv4/v6 also runs
|
||||
* the private-range check synchronously here
|
||||
* 3. port: integer 1..65535; port 22 explicitly rejected (SSH, not HTTP)
|
||||
* 4. tags: array of strings, max 20 items, each 1..50 chars, no control chars
|
||||
*
|
||||
* Note: DNS-rebinding check is async (resolveAndCheckAddress) and runs
|
||||
* separately, because this function is kept synchronous for testability.
|
||||
* Callers MUST invoke resolveAndCheckAddress after validateFleetHost
|
||||
* for DNS-named hosts.
|
||||
*/
|
||||
function validateFleetHost(input) {
|
||||
const { name, hostname, port, tags } = input || {};
|
||||
|
||||
if (typeof name !== 'string' || name.length === 0 || name.length > 100) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_NAME',
|
||||
message: 'name is required and must be 1..100 characters',
|
||||
};
|
||||
}
|
||||
// Disallow control chars in name (newlines would let a stored name break
|
||||
// log-file formats and could enable log injection if not properly escaped).
|
||||
if (/[\x00-\x1f]/.test(name)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_NAME',
|
||||
message: 'name must not contain control characters',
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof hostname !== 'string' || hostname.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_HOSTNAME',
|
||||
message: 'hostname is required',
|
||||
};
|
||||
}
|
||||
// Hard syntax check (catches obvious junk before any DNS call). Use
|
||||
// `net.isIP` to detect literal IPv4/IPv6 (handles both pure-v6 AND the
|
||||
// IPv4-mapped v6 `::ffff:x.y.z.w` correctly), then fall back to the
|
||||
// RFC 1123 DNS-name check.
|
||||
const syntaxIpFamily = require('net').isIP(hostname);
|
||||
if (syntaxIpFamily === 0 && !isValidHostnameSyntax(hostname)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_HOSTNAME',
|
||||
message: 'hostname must be a valid IPv4 address, IPv6 address, or DNS name',
|
||||
};
|
||||
}
|
||||
// If it's a literal IP, run the private-range check synchronously here.
|
||||
// Use `net.isIP` to distinguish a real IPv4 dotted-quad or IPv6 from
|
||||
// URL-shaped junk like `http://evil.com` (which contains both `:` and `.`
|
||||
// but is not a valid IP literal).
|
||||
const net = require('net');
|
||||
const ipFamily = net.isIP(hostname);
|
||||
if (ipFamily === 4) {
|
||||
const v4Check = isPrivateOrReservedIPv4(hostname);
|
||||
if (v4Check.isPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV4',
|
||||
message: `IPv4 address "${hostname}" is a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
} else if (ipFamily === 6) {
|
||||
const v6Check = isPrivateOrReservedIPv6(hostname);
|
||||
if (v6Check.isPrivate) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'PRIVATE_IPV6',
|
||||
message: `IPv6 address "${hostname}" is a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Port bounds + SSH sentinel.
|
||||
const portNum = Number(port);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_PORT',
|
||||
message: 'port must be an integer in 1..65535',
|
||||
};
|
||||
}
|
||||
if (portNum === 22) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_PORT',
|
||||
message: 'port 22 is reserved (SSH); the fleet API probe is HTTP, not SSH',
|
||||
};
|
||||
}
|
||||
|
||||
// Tags — array of short strings.
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_TAGS',
|
||||
message: 'tags must be an array of strings',
|
||||
};
|
||||
}
|
||||
if (tags.length > 20) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_TAGS',
|
||||
message: 'tags may contain at most 20 entries',
|
||||
};
|
||||
}
|
||||
for (const t of tags) {
|
||||
if (typeof t !== 'string' || t.length === 0 || t.length > 50) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_TAGS',
|
||||
message: 'each tag must be a string of 1..50 characters',
|
||||
};
|
||||
}
|
||||
if (/[\x00-\x1f]/.test(t)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_TAGS',
|
||||
message: 'tags must not contain control characters',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
normalized: {
|
||||
name: name.trim(),
|
||||
hostname: hostname.toLowerCase(),
|
||||
port: portNum,
|
||||
tags: tags || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateFleetHost,
|
||||
resolveAndCheckAddress,
|
||||
isPrivateOrReservedIPv4,
|
||||
isPrivateOrReservedIPv6,
|
||||
isValidHostnameSyntax,
|
||||
};
|
||||
@@ -131,13 +131,35 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
*
|
||||
* Caller-provided `Origin` header (via opts.headers) wins so tests / future
|
||||
* proxies can override; default matches the parsed admin URL.
|
||||
*
|
||||
* IMPORTANT — IPv6 path (DC-069): on Linux, `dns.lookup('localhost')` returns
|
||||
* `::1` FIRST (per RFC 3484, because /etc/hosts has `::1 localhost`). When the
|
||||
* caller passes `http://localhost:2019/...`, `parsed.hostname` is `::1` AND
|
||||
* the auto-injected Origin is `http://[::1]:2019` — which means the Caddy
|
||||
* `origins` allowlist MUST contain `http://[::1]:2019` (and ideally
|
||||
* `http://ip6-localhost:2019` for the glibc alias), otherwise every on-host
|
||||
* Node probe via `localhost` gets a 403 with empty-Origin-looking error.
|
||||
* The corresponding `origins` entries live in `/etc/caddy/Caddyfile` on DNS2
|
||||
* (committed via `caddy-apply`) and are documented in
|
||||
* `dashcaddy-installer/templates/Caddyfile.template`.
|
||||
*/
|
||||
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`;
|
||||
// Node 22's WHATWG URL parser preserves the brackets around IPv6
|
||||
// literals in `parsed.hostname` (e.g. '[::1]'), but `http.request({hostname})`
|
||||
// expects the BRACKETLESS form for actual connection — passing '[::1]'
|
||||
// triggers `getaddrinfo ENOTFOUND [::1]` and the request fails before
|
||||
// any Origin matching happens. Caddy's `enforce_origin` allowlist
|
||||
// matches by exact Origin string (which DOES include the brackets),
|
||||
// so we keep `defaultOrigin` bracket-form for the header but strip them
|
||||
// for the transport-layer hostname. (DC-069 — IPv6 admin probe path.)
|
||||
const transportHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
|
||||
? parsed.hostname.slice(1, -1)
|
||||
: parsed.hostname;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
hostname: transportHostname,
|
||||
port: parsed.port || 2019,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: (opts.method || 'GET').toUpperCase(),
|
||||
|
||||
@@ -62,8 +62,34 @@ function noContent(res) {
|
||||
*
|
||||
* DC-086: If extras.code is set, it's treated as a machine-readable error code
|
||||
* (e.g. 'DC-CONT-002'). If message looks like a DC code, it's auto-extracted.
|
||||
*
|
||||
* DC-062: Validate that `statusCode` is a valid HTTP status (integer in
|
||||
* 100..599) BEFORE calling res.status(). Without this guard, a caller who
|
||||
* passes (res, message, statusCode) instead of (res, statusCode, message)
|
||||
* ends up with res.status(<string>), which throws
|
||||
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] — Express catches that and
|
||||
* writes a 500 with an HTML stack trace to the client, which is the worst
|
||||
* possible failure mode (looks like a server crash, breaks CSRF and
|
||||
* content-type expectations, leaks the stack). Failing fast with a clear
|
||||
* TypeError names the call site early in the request lifecycle.
|
||||
*/
|
||||
function errorResponse(res, statusCode, message, extras = {}) {
|
||||
if (
|
||||
typeof statusCode !== 'number'
|
||||
|| !Number.isFinite(statusCode)
|
||||
|| !Number.isInteger(statusCode)
|
||||
|| statusCode < 100
|
||||
|| statusCode > 599
|
||||
) {
|
||||
throw new TypeError(
|
||||
`errorResponse(res, statusCode, message, extras): statusCode must be an integer HTTP status (100..599); received ${JSON.stringify(statusCode)} (message=${JSON.stringify(message)})`
|
||||
);
|
||||
}
|
||||
if (typeof message !== 'string') {
|
||||
throw new TypeError(
|
||||
`errorResponse(res, statusCode, message, extras): message must be a string; received ${typeof message} ${JSON.stringify(message)}`
|
||||
);
|
||||
}
|
||||
const body = { success: false, error: message, ...extras };
|
||||
// DC-086: surface machine-readable code at top level for client handling
|
||||
if (extras.code) {
|
||||
|
||||
@@ -13,6 +13,31 @@
|
||||
*/
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
/**
|
||||
* Parse the `Cookie` header into a plain `{name: value}` map.
|
||||
* WS upgrade requests don't go through Express's cookie-parser, so we
|
||||
* do it by hand here. We deliberately do NOT decode the values — the
|
||||
* session-cookie HMAC verifier reads the raw cookie string verbatim
|
||||
* (`payloadB64.sig` shape), so any decoding (e.g. url-decode) would
|
||||
* corrupt the signature. Single cookie-pair per call, no nesting.
|
||||
*
|
||||
* @param {string|undefined} header - Raw Cookie header value
|
||||
* @returns {Object<string, string>} name → value map (empty string for blanks)
|
||||
*/
|
||||
function parseCookieHeader(header) {
|
||||
const out = {};
|
||||
if (!header) return out;
|
||||
for (const part of header.split(';')) {
|
||||
const idx = part.indexOf('=');
|
||||
if (idx === -1) continue;
|
||||
const name = part.slice(0, idx).trim();
|
||||
if (!name) continue;
|
||||
const value = part.slice(idx + 1).trim();
|
||||
out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function createDashboardWS(server, deps = {}) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
@@ -30,9 +55,52 @@ function createDashboardWS(server, deps = {}) {
|
||||
log,
|
||||
} = deps;
|
||||
|
||||
// ── Auth verifier (injected by server.js from app.locals.ctx.session) ──
|
||||
// The WS upgrade path bypasses Express middleware, so the global
|
||||
// `totpAuthMiddleware` (which calls `isSessionValid(req)`) never runs.
|
||||
// We accept the SAME verifier here so a valid browser session cookie
|
||||
// grants access and nothing else does.
|
||||
//
|
||||
// The injected verifier receives the raw HTTP upgrade request (an
|
||||
// IncomingMessage with `.headers.cookie`). Production wires
|
||||
// `app.locals.ctx.session.isValid` directly — it accepts the same
|
||||
// shape, parses the Cookie header internally, and runs the HMAC
|
||||
// check. Tests inject a stub.
|
||||
//
|
||||
// DC-061 hardening: prior code only checked that the `dashcaddy_session`
|
||||
// SUBSTRING appeared in the Cookie header. That let an attacker set any
|
||||
// cookie named `dashcaddy_session=garbage` (or include the literal text
|
||||
// in another cookie's value) and bypass auth. The injected verifier
|
||||
// runs HMAC validation, so a present-but-invalid cookie now 401s.
|
||||
const authVerifier = typeof deps.authVerifier === 'function'
|
||||
? deps.authVerifier
|
||||
: (req) => {
|
||||
// Last-resort fallback: presence-only check on a non-empty
|
||||
// session-cookie value. Used only when the caller didn't inject
|
||||
// a real verifier (e.g. tests, unusual boot paths). Production
|
||||
// wires the real one from app.locals.ctx.session.isValid.
|
||||
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
|
||||
const raw = parsed.dashcaddy_session || parsed.sid;
|
||||
return typeof raw === 'string' && raw.length > 0;
|
||||
};
|
||||
|
||||
// Track connected clients and their subscriptions
|
||||
const wsClients = new Set();
|
||||
|
||||
// Track the listener functions we attach to shared EventEmitters so we
|
||||
// can detach exactly OUR listeners on close() — without disturbing the
|
||||
// SSE route's listeners on the same emitters. DC-061 critical fix:
|
||||
// the previous code called `resourceMonitor.removeAllListeners()` which
|
||||
// silently killed the SSE route's `alert`/`status-check`/etc subscribers
|
||||
// whenever close() ran (hot reload, graceful restart).
|
||||
const emitterListeners = [];
|
||||
|
||||
function attachListener(emitter, event, handler) {
|
||||
if (!emitter || typeof emitter.on !== 'function') return;
|
||||
emitter.on(event, handler);
|
||||
emitterListeners.push({ emitter, event, handler });
|
||||
}
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = JSON.stringify({ type: 'event', event, data });
|
||||
for (const client of wsClients) {
|
||||
@@ -49,62 +117,46 @@ function createDashboardWS(server, deps = {}) {
|
||||
|
||||
// ── Wire up EventEmitter listeners (same events as SSE) ──
|
||||
|
||||
if (resourceMonitor) {
|
||||
resourceMonitor.on('alert', (data) => broadcast('resource-alert', data));
|
||||
resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data));
|
||||
}
|
||||
attachListener(resourceMonitor, 'alert', (data) => broadcast('resource-alert', data));
|
||||
attachListener(resourceMonitor, 'auto-restart', (data) => broadcast('auto-restart', data));
|
||||
|
||||
if (healthChecker) {
|
||||
healthChecker.on('status-check', (data) => {
|
||||
broadcast('status-change', {
|
||||
serviceId: data.serviceId,
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
responseTime: data.responseTime,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
attachListener(healthChecker, 'status-check', (data) => {
|
||||
broadcast('status-change', {
|
||||
serviceId: data.serviceId,
|
||||
name: data.name,
|
||||
status: data.status,
|
||||
responseTime: data.responseTime,
|
||||
timestamp: data.timestamp,
|
||||
});
|
||||
healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
|
||||
healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
|
||||
}
|
||||
});
|
||||
attachListener(healthChecker, 'incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
|
||||
attachListener(healthChecker, 'incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
|
||||
|
||||
if (updateManager) {
|
||||
updateManager.on('update-available', (data) => broadcast('update-available', data));
|
||||
updateManager.on('update-start', (data) => broadcast('update-start', data));
|
||||
updateManager.on('update-complete', (data) => broadcast('update-complete', data));
|
||||
updateManager.on('update-failed', (data) => broadcast('update-failed', data));
|
||||
updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data));
|
||||
updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data));
|
||||
}
|
||||
attachListener(updateManager, 'update-available', (data) => broadcast('update-available', data));
|
||||
attachListener(updateManager, 'update-start', (data) => broadcast('update-start', data));
|
||||
attachListener(updateManager, 'update-complete', (data) => broadcast('update-complete', data));
|
||||
attachListener(updateManager, 'update-failed', (data) => broadcast('update-failed', data));
|
||||
attachListener(updateManager, 'auto-update-start', (data) => broadcast('auto-update-start', data));
|
||||
attachListener(updateManager, 'auto-update-complete', (data) => broadcast('auto-update-complete', data));
|
||||
|
||||
if (dependencyManager) {
|
||||
dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
|
||||
dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
|
||||
dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
|
||||
dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
|
||||
}
|
||||
attachListener(dependencyManager, 'dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
|
||||
attachListener(dependencyManager, 'dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
|
||||
attachListener(dependencyManager, 'dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
|
||||
attachListener(dependencyManager, 'dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
|
||||
|
||||
if (autoRestartManager) {
|
||||
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
||||
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
||||
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
||||
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
||||
}
|
||||
attachListener(autoRestartManager, 'auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
||||
attachListener(autoRestartManager, 'auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
||||
attachListener(autoRestartManager, 'auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
||||
attachListener(autoRestartManager, 'auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
||||
|
||||
if (driftDetector) {
|
||||
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
|
||||
}
|
||||
attachListener(driftDetector, 'drift-detected', (data) => broadcast('drift-detected', data));
|
||||
|
||||
if (sslMonitor) {
|
||||
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
|
||||
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
|
||||
}
|
||||
attachListener(sslMonitor, 'cert-expiring', (data) => broadcast('cert-expiring', data));
|
||||
attachListener(sslMonitor, 'cert-critical', (data) => broadcast('cert-critical', data));
|
||||
|
||||
if (dnsPropagationChecker) {
|
||||
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
|
||||
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
||||
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
||||
}
|
||||
attachListener(dnsPropagationChecker, 'propagation-check', (data) => broadcast('dns-propagation-check', data));
|
||||
attachListener(dnsPropagationChecker, 'propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
||||
attachListener(dnsPropagationChecker, 'propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
||||
|
||||
// ── Handle upgrade requests at /api/v1/ws ──
|
||||
|
||||
@@ -116,16 +168,21 @@ function createDashboardWS(server, deps = {}) {
|
||||
return; // Let other upgrade handlers deal with it
|
||||
}
|
||||
|
||||
// DC-076: Auth check — extract session/token from query params or cookies
|
||||
// The SSE endpoint is behind auth middleware; WS needs the same gate.
|
||||
// We validate the session cookie or API token before accepting the upgrade.
|
||||
const cookies = (request.headers.cookie || '');
|
||||
const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid');
|
||||
const token = url.searchParams.get('token');
|
||||
const hasToken = token && token.length > 10;
|
||||
// DC-061 auth gate: WS upgrade bypasses Express middleware, so we
|
||||
// must validate the session here. We accept ONLY a valid signed
|
||||
// session cookie (no `token` query-param bypass — that was the
|
||||
// previous footgun, which granted access to any random 11+ char
|
||||
// string in production). The verifier is injected from
|
||||
// app.locals.ctx.session.isValid in production.
|
||||
const ok = authVerifier(request);
|
||||
|
||||
if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
if (!ok) {
|
||||
const ip = (request.socket && request.socket.remoteAddress) || 'unknown';
|
||||
if (log && log.warn) {
|
||||
log.warn('websocket', 'WS upgrade rejected — no valid session', { ip, path: url.pathname });
|
||||
}
|
||||
// 401 + Connection: close so the client doesn't retry.
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -170,6 +227,17 @@ function createDashboardWS(server, deps = {}) {
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
// Cap message size at 16 KB — defense-in-depth against a malicious
|
||||
// peer that exploits ws's message framing to flood our parser.
|
||||
// The `ws` library already enforces this via its constructor option,
|
||||
// but a second guard at the handler level catches any future
|
||||
// regressions (e.g. someone passing `maxPayload` differently).
|
||||
if (raw.length > 16 * 1024) {
|
||||
ws.send(JSON.stringify({ type: 'error', error: 'Message too large' }));
|
||||
try { ws.close(1009, 'Message too large'); } catch { /* already closed */ }
|
||||
return;
|
||||
}
|
||||
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
@@ -248,12 +316,21 @@ function createDashboardWS(server, deps = {}) {
|
||||
}
|
||||
wsClients.clear();
|
||||
wss.close();
|
||||
// Remove all listeners from the event emitters to prevent leaks on restart
|
||||
if (resourceMonitor) resourceMonitor.removeAllListeners();
|
||||
if (healthChecker) healthChecker.removeAllListeners();
|
||||
if (updateManager) updateManager.removeAllListeners();
|
||||
// DC-061: detach ONLY the listeners we attached. Previously the
|
||||
// module called `resourceMonitor.removeAllListeners()` (and same
|
||||
// for healthChecker / updateManager), which silently wiped the
|
||||
// SSE route's listeners on the same shared emitters — the SSE
|
||||
// stream went dead the moment close() ran (hot reload, restart).
|
||||
for (const { emitter, event, handler } of emitterListeners) {
|
||||
if (emitter && typeof emitter.removeListener === 'function') {
|
||||
emitter.removeListener(event, handler);
|
||||
}
|
||||
}
|
||||
emitterListeners.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = createDashboardWS;
|
||||
module.exports.createDashboardWS = createDashboardWS;
|
||||
module.exports.parseCookieHeader = parseCookieHeader;
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
|
||||
# Global options
|
||||
{
|
||||
# The default `admin localhost:2019` binds to the loopback interface, so
|
||||
# Caddy's `enforce_origin` CSRF guard is never engaged and no `origins`
|
||||
# directive is required. (Note: glibc resolves `localhost` to `::1`
|
||||
# first per RFC 3484, so `admin localhost:2019` typically binds BOTH
|
||||
# IPv4 and IPv6 loopback — the actionable point is that any loopback
|
||||
# bind skips enforce_origin, not the exact IPv4/IPv6 split.)
|
||||
#
|
||||
# If a non-loopback bind is adopted later (e.g. `admin 0.0.0.0:2019 { ... }`
|
||||
# so a docker container on the host's bridge can reach admin via
|
||||
# 172.17.0.1:2019), the admin block MUST include an `origins` allowlist.
|
||||
# On Linux, `localhost` resolves to `::1` FIRST per glibc RFC 3484 (because
|
||||
# /etc/hosts has `::1 localhost`), so allowlist entries must include the
|
||||
# IPv6 literal form `http://[::1]:2019` AND `http://ip6-localhost:2019`
|
||||
# (the glibc alias) — `http://localhost:2019` alone will 403 every probe
|
||||
# that resolves localhost to `::1`. See DC-051 + DC-069 in repo history.
|
||||
admin localhost:2019
|
||||
auto_https off
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user