Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ec6ebc53 | ||
|
|
a7260436d1 | ||
|
|
a4e4b24732 | ||
|
|
0086de97da | ||
|
|
18ffd2e519 | ||
|
|
2fef1c47e5 | ||
|
|
e8c5a7a1fb | ||
|
|
270e8d57e3 | ||
|
|
7db152499c | ||
|
|
a9bb4a1835 | ||
|
|
b64f23301b | ||
|
|
83d7c65bf2 | ||
|
|
1462024944 | ||
|
|
297332b0e1 | ||
|
|
384f9c8bdb | ||
|
|
933606ce3f | ||
|
|
5382d832d9 | ||
|
|
c6b2f556c2 | ||
|
|
4e75b13e90 | ||
|
|
597bbf67c8 | ||
|
|
a2e2a12eb8 | ||
|
|
c01a011d47 | ||
|
|
74fe35d969 | ||
|
|
678a0160c4 | ||
|
|
9779feae70 | ||
|
|
30d5fdbb2c | ||
|
|
71d20ceef3 | ||
|
|
87f76aef66 |
@@ -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,112 @@
|
||||
/**
|
||||
* Nesting-guard tests — DC-077 (data/data recursive duplicate cleanup)
|
||||
*
|
||||
* The guard runs at app startup. Pre-fix, `src/config/paths.js` did NOT
|
||||
* re-export `dataDir`, so `paths.dataDir` resolved to `undefined`. The
|
||||
* outer try/catch swallowed the resulting `TypeError [ERR_INVALID_ARG_TYPE]`
|
||||
* and the entire guard became a silent no-op — every startup logged
|
||||
* `[nesting-guard] Skipped: The "path" argument must be of type string.
|
||||
* Received undefined`. Post-fix, paths.js exports `dataDir` and the guard
|
||||
* falls back to platform-paths directly if `paths.dataDir` is missing.
|
||||
*
|
||||
* Tests use jest.isolateModules() for clean module-cache isolation.
|
||||
* jest.doMock is intentionally avoided — it persists across tests in a
|
||||
* describe and is the root cause of subtle flakes.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('nesting-guard (DC-077)', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeTmpTree() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'nest-guard-'));
|
||||
}
|
||||
|
||||
function writeJson(p, obj) {
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(obj));
|
||||
}
|
||||
|
||||
it('removes a recursive data/data duplicate when present', () => {
|
||||
const tmp = makeTmpTree();
|
||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||
writeJson(path.join(tmp, 'data', 'config.json'), { x: 1 });
|
||||
writeJson(path.join(tmp, 'data', 'services.json'), []);
|
||||
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let cleanupLog = '';
|
||||
let warnLog = '';
|
||||
jest.isolateModules(() => {
|
||||
const guard = require('../src/utilities/nesting-guard');
|
||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||
guard();
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(tmp, 'data'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||
expect(cleanupLog).toMatch(/Removing recursive data nesting|Recursive nesting removed/);
|
||||
expect(warnLog).not.toMatch(/Skipped/);
|
||||
});
|
||||
|
||||
it('does nothing when no nested data/data directory exists', () => {
|
||||
const tmp = makeTmpTree();
|
||||
writeJson(path.join(tmp, 'config.json'), { x: 1 });
|
||||
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let cleanupLog = '';
|
||||
let warnLog = '';
|
||||
jest.isolateModules(() => {
|
||||
const guard = require('../src/utilities/nesting-guard');
|
||||
jest.spyOn(console, 'log').mockImplementation((m) => { cleanupLog += String(m) + '\n'; });
|
||||
jest.spyOn(console, 'warn').mockImplementation((m) => { warnLog += String(m) + '\n'; });
|
||||
guard();
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(tmp, 'config.json'))).toBe(true);
|
||||
expect(warnLog).not.toMatch(/Skipped/);
|
||||
expect(cleanupLog).not.toMatch(/Removing recursive data nesting/);
|
||||
});
|
||||
|
||||
it('src/config/paths exports dataDir as a non-empty string', () => {
|
||||
let dataDir;
|
||||
jest.isolateModules(() => {
|
||||
const paths = require('../src/config/paths');
|
||||
dataDir = paths.dataDir;
|
||||
});
|
||||
expect(typeof dataDir).toBe('string');
|
||||
expect(dataDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('src/config/paths.dataDir equals dirname(SERVICES_FILE) when SERVICES_FILE env is set', () => {
|
||||
const tmp = makeTmpTree();
|
||||
process.env.SERVICES_FILE = path.join(tmp, 'services.json');
|
||||
process.env.CONFIG_FILE = path.join(tmp, 'config.json');
|
||||
|
||||
let servicesFile, dataDir;
|
||||
jest.isolateModules(() => {
|
||||
const paths = require('../src/config/paths');
|
||||
servicesFile = paths.SERVICES_FILE;
|
||||
dataDir = paths.dataDir;
|
||||
});
|
||||
|
||||
expect(dataDir).toBe(path.dirname(servicesFile));
|
||||
expect(dataDir).toBe(tmp);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* DC-076: Per-service CA cert / private key disclosure hardening
|
||||
*
|
||||
* Bug class:
|
||||
* 1. /api/v1/ca/cert/<domain> and /api/v1/ca/certs were listed in
|
||||
* middleware.js PUBLIC_ROUTES. TOTP/session is the gate; if an
|
||||
* operator ever disables TOTP (ops command, fresh-install setup
|
||||
* state, .disabled-* rename of totp-config.json), an unauthenticated
|
||||
* attacker reaching `https://ca.sami/api/ca/cert/<domain>?format=key`
|
||||
* would receive the per-service RSA private key for any domain whose
|
||||
* cert Caddy has ever signed — that's a per-service key disclosure,
|
||||
* not just a CA fingerprint leak. Even WITH TOTP enabled, any
|
||||
* read-scope credential could pull a private key, which is over-
|
||||
* privileged for "I just want to look at the dashboard".
|
||||
* 2. The route's `password` query param defaulted to the literal string
|
||||
* `'dashcaddy'` — a hardcoded credential published in source. Every
|
||||
* PFX file Caddy signed silently used the same published password.
|
||||
* 3. The route had no rate limit — every request forks an `openssl`
|
||||
* process and writes to disk, so an authenticated admin in a loop
|
||||
* could exhaust CPU/IO.
|
||||
*
|
||||
* Post-fix (this commit):
|
||||
* 1. /api/v1/ca/cert/<domain> + /api/v1/ca/certs removed from
|
||||
* PUBLIC_ROUTES — TOTP/session always required.
|
||||
* 2. The route additionally requires `admin` scope (defense in depth
|
||||
* against future middleware-ordering mistakes and against the case
|
||||
* where TOTP is enabled but a read-scope API key is in use).
|
||||
* 3. PFX format now REQUIRES an explicit 8-64 char password (no
|
||||
* default). Other formats (key, pem, crt, fullchain) reject `=`
|
||||
* in the password arg to keep copy-paste mistakes from
|
||||
* contaminating logs.
|
||||
* 4. Per-IP rate limit: 10 req/min/IP with Retry-After + 429.
|
||||
*
|
||||
* The suite covers:
|
||||
* 1. middleware PUBLIC_ROUTES no longer contains the ca cert/certs paths
|
||||
* 2. /cert/<domain> rejects with 403 when no admin scope (read scope,
|
||||
* missing scope, malformed scope all rejected)
|
||||
* 3. /cert/<domain> rejects with 400 when PFX password missing or weak
|
||||
* 4. /cert/<domain> rejects with 400 when domain is malformed
|
||||
* (path traversal, single label, control chars)
|
||||
* 5. /cert/<domain> returns 200 + cert bytes when admin scope + valid
|
||||
* password supplied (mocked openssl)
|
||||
* 6. Rate limit: 10 req/min/IP allowed, 11th 429 with Retry-After
|
||||
* 7. /certs list endpoint requires admin scope (regression for the
|
||||
* public listing)
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// We pull the route's internal helpers by requiring the module under test
|
||||
// and inspecting its internals via the closure-scoped functions. The cleanest
|
||||
// path is to mount the route and assert behavior end-to-end through HTTP.
|
||||
const caRoutes = require('../../routes/ca');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixture: a minimal Express app that mounts /ca with stubbed ctx.
|
||||
// The route captures `platformPaths` at module-load time, so the actual
|
||||
// production paths are used. Test scenarios that would need an isolated
|
||||
// cert dir are covered at the response-shape level (asserting 400/403/429
|
||||
// codes) rather than the file-content level.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCaApp({ scope, installMocks = true, tempDirs } = {}) {
|
||||
// We don't mock platform-paths because the test scenarios that need
|
||||
// filesystem-isolated cert dirs (PFX, cert-file serving) are covered
|
||||
// by their pre-staged files in the system temp dir, and the 200-happy
|
||||
// path for non-PFX formats is asserted at the response-shape level
|
||||
// rather than the file-content level. The route's pre-existing PKI
|
||||
// files at the real platformPaths.pkiDir either exist (production
|
||||
// setup) or trigger the 500 "CA certificates not found" path — both
|
||||
// are acceptable for the scope/admin/password/rate-limit assertions.
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const caRoutes = require('../../routes/ca');
|
||||
|
||||
const ok = (res, data) => res.json({ ok: true, ...data });
|
||||
const errorResponse = (res, statusCode, message, extras) => {
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: message,
|
||||
code: (extras && extras.code) || null,
|
||||
...(extras || {}),
|
||||
});
|
||||
};
|
||||
const asyncHandler = wrap;
|
||||
|
||||
const ctx = {
|
||||
asyncHandler,
|
||||
ok,
|
||||
errorResponse,
|
||||
siteConfig: { tld: '.sami' },
|
||||
};
|
||||
const ca = caRoutes(ctx);
|
||||
|
||||
// Mount a tiny auth shim that stamps req.auth before the route runs.
|
||||
// This mirrors what the global totpAuthMiddleware + jwtApiKeyAuthMiddleware
|
||||
// do in production: req.auth = { type, scope, ... }.
|
||||
app.use((req, _res, next) => {
|
||||
req.auth = { type: 'session', scope: scope || [] };
|
||||
// req.ip is read by the rate limiter
|
||||
req.ip = '127.0.0.1';
|
||||
next();
|
||||
});
|
||||
app.use('/ca', ca);
|
||||
return { app };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-076: CA cert/key disclosure hardening', () => {
|
||||
describe('middleware PUBLIC_ROUTES no longer whitelists the per-service cert/key endpoints', () => {
|
||||
// Read the public-routes source so a future refactor that re-adds the
|
||||
// path is caught by THIS test (not by an external integration test
|
||||
// that depends on running TOTP-disabled).
|
||||
const fs = require('fs');
|
||||
const middlewareSrc = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/utilities/middleware.js'), 'utf8');
|
||||
// Extract the PUBLIC_ROUTES block (best-effort text scan — catches
|
||||
// both `path: '/api/v1/ca/cert/...'` and `path: '/api/v1/ca/certs'`).
|
||||
const caCertEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/cert\/[^'"]*['"]/);
|
||||
const caCertsEntry = middlewareSrc.match(/path:\s*['"]\/api\/v1\/ca\/certs['"]/);
|
||||
|
||||
test('/api/v1/ca/cert/ prefix is NOT in PUBLIC_ROUTES', () => {
|
||||
expect(caCertEntry).toBeNull();
|
||||
});
|
||||
test('/api/v1/ca/certs exact path is NOT in PUBLIC_ROUTES', () => {
|
||||
expect(caCertsEntry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('/cert/:domain — admin scope required (defense in depth)', () => {
|
||||
test('no scope at all -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
||||
const { app } = createCaApp({ scope: [] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=key');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
||||
expect(res.body.requiredScope).toBe('admin');
|
||||
});
|
||||
|
||||
test('read-only scope -> 403 with DC-076_INSUFFICIENT_SCOPE', async () => {
|
||||
const { app } = createCaApp({ scope: ['read'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=key');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('DC-076_INSUFFICIENT_SCOPE');
|
||||
expect(res.body.actualScope).toEqual(['read']);
|
||||
});
|
||||
|
||||
test('write scope (but not admin) -> 403', async () => {
|
||||
const { app } = createCaApp({ scope: ['read', 'write'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=key');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('admin scope -> proceeds past the scope gate', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=key');
|
||||
// Will fail later (no password? actually format=key doesn't need pw)
|
||||
// but MUST NOT 403. We expect a 4xx for the cert file not existing
|
||||
// (the test stubs open the route, but the openssl mock below would
|
||||
// still hit a real openssl — we test 200 only when mocks are wired).
|
||||
// For the no-mock path, we accept anything except 403.
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
test('scope field coerced defensively (string, not array) -> 403', async () => {
|
||||
const { app } = createCaApp({ scope: 'admin' });
|
||||
// Override the auth shim to set a malformed scope
|
||||
app.use((req, _res, next) => {
|
||||
req.auth = { type: 'session', scope: 'admin' /* not an array */ };
|
||||
next();
|
||||
});
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=key');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/cert/:domain — PFX format requires explicit password', () => {
|
||||
test('no password supplied -> 400 DC-076_PASSWORD_REQUIRED', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=pfx');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
||||
});
|
||||
|
||||
test('default password "dashcaddy" was the pre-fix behavior — now rejected', async () => {
|
||||
// Pre-fix: the route used `password = 'dashcaddy'` as default; PFX
|
||||
// files were signed with that string. Post-fix: an explicit password
|
||||
// shorter than 8 chars or matching the old default shape ("dashcaddy"
|
||||
// is 9 chars, lowercase only) must be REJECTED if it doesn't match
|
||||
// the policy. The policy is 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-],
|
||||
// so "dashcaddy" is technically 9 chars and would pass... but we
|
||||
// test that an EXPLICIT password is required (no implicit default)
|
||||
// by sending no password and asserting 400.
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const noPw = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=pfx');
|
||||
expect(noPw.status).toBe(400);
|
||||
expect(noPw.body.code).toBe('DC-076_PASSWORD_REQUIRED');
|
||||
});
|
||||
|
||||
test('short password (< 8 chars) -> 400 DC-076_PASSWORD_INVALID', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=pfx&password=short');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||
});
|
||||
|
||||
test('password with `=` -> 400 DC-076_PASSWORD_INVALID', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=pfx&password=abcdefgh=');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||
});
|
||||
|
||||
test('password with disallowed char (e.g. `/`) -> 400', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=pfx&password=abc/12345');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_PASSWORD_INVALID');
|
||||
});
|
||||
|
||||
test('non-PFX format (key) does NOT require a password (regression for PFX-only password logic)', async () => {
|
||||
// The point of this test is to prove that the new DC-076 password
|
||||
// gate only fires for PFX. Other formats (key, pem, crt, fullchain)
|
||||
// must not 400 on missing-password.
|
||||
//
|
||||
// We can't easily test the 200 happy path here because the route
|
||||
// calls `openssl x509 -in server.crt -noout -dates` to check cert
|
||||
// expiry, and a fake server.crt makes that fall through to cert
|
||||
// regeneration (which calls real openssl and writes real certs to
|
||||
// the real platformPaths.generatedCertsDir — not what we want in a
|
||||
// unit test). Instead, we assert that the route does NOT 400 with
|
||||
// the password-required shape. We use /format=crt which has the
|
||||
// simplest validation path.
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
// No password supplied; format=crt. Should NOT 400 with
|
||||
// DC-076_PASSWORD_REQUIRED (that's only for PFX).
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1.sami?format=crt');
|
||||
if (res.status === 400 && res.body.code === 'DC-076_PASSWORD_REQUIRED') {
|
||||
throw new Error('non-PFX format wrongly required a password: ' + JSON.stringify(res.body));
|
||||
}
|
||||
// The actual response could be 200 (cert served) or 500 (cert files
|
||||
// missing in test env, or openssl error from fake data) — both
|
||||
// are acceptable; what matters is NOT 400 DC-076_PASSWORD_REQUIRED.
|
||||
expect(res.status).not.toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/cert/:domain — domain validation', () => {
|
||||
test('rejects single-label domain (no dot)', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/dns1?format=key');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
||||
});
|
||||
|
||||
test('rejects domain with `..` (path traversal)', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/..%2Fetc%2Fpasswd?format=key');
|
||||
// Express decodes %2F in the path -> /ca/cert/../etc/passwd
|
||||
// The new regex `^[a-z0-9]...` rejects this entirely.
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('rejects domain with control char (\\n)', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/evil%0A.com?format=key');
|
||||
expect([400, 404]).toContain(res.status);
|
||||
});
|
||||
|
||||
test('rejects uppercase domain (must be lowercase per the new regex)', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app)
|
||||
.get('/ca/cert/DNS1.SAMI?format=key');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('DC-076_DOMAIN_INVALID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('/cert/:domain — rate limit', () => {
|
||||
test('first 10 requests in 60s succeed (or fail non-rate-limit), 11th returns 429', async () => {
|
||||
// 10 requests should all NOT be 429 (the rate-limit counter is
|
||||
// reset per module load, so each test starts fresh).
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const r = await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||
expect(r.status).not.toBe(429);
|
||||
}
|
||||
// 11th MUST be 429 (the rate limit is in-module state; only the
|
||||
// last test's app shares state with itself, so we use the same
|
||||
// app for the 11th request).
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
// First 10
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||
}
|
||||
const over = await request(app).get('/ca/cert/dns1.sami?format=key');
|
||||
expect(over.status).toBe(429);
|
||||
expect(over.body.code).toBe('DC-076_RATE_LIMITED');
|
||||
expect(over.headers['retry-after']).toMatch(/^\d+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/certs — list endpoint requires admin scope', () => {
|
||||
test('no admin scope -> 403', async () => {
|
||||
const { app } = createCaApp({ scope: ['read'] });
|
||||
const res = await request(app).get('/ca/certs');
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
test('admin scope -> 200', async () => {
|
||||
const { app } = createCaApp({ scope: ['admin'] });
|
||||
const res = await request(app).get('/ca/certs');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static /root.crt and /info remain public (CA cert IS public)', () => {
|
||||
test('GET /ca/root.crt does not require admin scope', async () => {
|
||||
const { app } = createCaApp({ scope: [] });
|
||||
const res = await request(app).get('/ca/root.crt');
|
||||
// 200 if the file is there, 404 if not — but NEVER 403
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
test('GET /ca/info does not require admin scope', async () => {
|
||||
const { app } = createCaApp({ scope: [] });
|
||||
const res = await request(app).get('/ca/info');
|
||||
// 200 if cert-info.json is there, 404 if not — but NEVER 403
|
||||
expect([200, 404]).toContain(res.status);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,11 @@ function createDiscoverApp(docker, servicesStateManager) {
|
||||
|
||||
function createDisasterApp(platformPaths, log) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
// Match the production body-parser limit (1 MiB) so the in-handler
|
||||
// DC-079 cap (512 KiB) is actually reachable from tests. The default
|
||||
// express.json() limit is 100 KiB, which would short-circuit the test
|
||||
// with a 413 before the route's defense-in-depth check runs.
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const routes = require('../../routes/disaster-recovery');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap }));
|
||||
@@ -135,4 +139,267 @@ describe('DC-107: Disaster Recovery', () => {
|
||||
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
||||
expect(svc[0].id).toBe('restored-svc');
|
||||
});
|
||||
|
||||
// DC-079: Caddyfile restore hardening — the live Caddyfile path must
|
||||
// NEVER be written from the disaster-recovery endpoint. The endpoint
|
||||
// stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate
|
||||
// and surfaces a warning that `caddy-apply` is required to apply it.
|
||||
it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => {
|
||||
// The env var CADDYFILE_PATH is read by the route. Use a sentinel
|
||||
// path that we can prove was NOT written. The route must instead
|
||||
// create <dataDir>/disaster-staged/Caddyfile.candidate.
|
||||
const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt');
|
||||
fs.writeFileSync(liveSentinel, 'do-not-overwrite');
|
||||
|
||||
const candidateCaddyfile =
|
||||
'# staged candidate\n' +
|
||||
'example.com {\n' +
|
||||
' respond "ok"\n' +
|
||||
'}\n';
|
||||
|
||||
const app = createDisasterApp({
|
||||
dataDir: tmpDir,
|
||||
caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response
|
||||
});
|
||||
// Override process.env.CADDYFILE_PATH so the route picks up our sentinel
|
||||
const prev = process.env.CADDYFILE_PATH;
|
||||
process.env.CADDYFILE_PATH = liveSentinel;
|
||||
try {
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: candidateCaddyfile,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('success');
|
||||
expect(res.body.caddyfileStaged).toBeTruthy();
|
||||
expect(res.body.caddyfileStaged).toHaveLength(1);
|
||||
expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile');
|
||||
expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply');
|
||||
expect(res.body.caddyfileStaged[0].stagedPath).toBe(
|
||||
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate')
|
||||
);
|
||||
expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel);
|
||||
expect(res.body.warning).toMatch(/DC-079/);
|
||||
|
||||
// The live sentinel file is UNTOUCHED — still has its original content.
|
||||
const liveContents = fs.readFileSync(liveSentinel, 'utf8');
|
||||
expect(liveContents).toBe('do-not-overwrite');
|
||||
|
||||
// The candidate file IS staged at the staging path.
|
||||
const stagedContents = fs.readFileSync(
|
||||
path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'),
|
||||
'utf8'
|
||||
);
|
||||
expect(stagedContents).toBe(candidateCaddyfile);
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.CADDYFILE_PATH;
|
||||
else process.env.CADDYFILE_PATH = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: { evil: 'object' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Caddyfile content must be a string/);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: '', // explicit empty payload — rejected
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Caddyfile content is empty/);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
// 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit
|
||||
const huge = 'a'.repeat(512 * 1024 + 1);
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: huge,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/exceeds 524288 bytes/);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const evil =
|
||||
'# malicious snapshot\n' +
|
||||
'import /etc/caddy/external.caddy\n' +
|
||||
'example.com { respond "ok" }\n';
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: evil,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||
|
||||
// No staging file should have been created — fail closed.
|
||||
expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const evil =
|
||||
'# malicious snapshot\n' +
|
||||
'import ../../../etc/passwd\n' +
|
||||
'example.com { respond "ok" }\n';
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: evil,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const evil =
|
||||
'import %2fetc%2fcaddy%2fevil.caddy\n' +
|
||||
'example.com { respond "ok" }\n';
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
caddyfile: evil,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/forbidden `import` directive/);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
files: {
|
||||
services: [{ id: 'no-caddy' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.caddyfileStaged).toBeUndefined();
|
||||
expect(res.body.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
// DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal.
|
||||
// Without the assertSafeAssetKey / assertSafeThemeName + path.resolve
|
||||
// checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||
// "<base64-evil>"}}` and overwrite the live Caddyfile via the dataDir
|
||||
// bind-mount. These tests prove the fix.
|
||||
it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
assets: {
|
||||
'../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'),
|
||||
'custom-logo.png': Buffer.from('legit-logo').toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
// The traversal key is rejected (added to errors), the legit key
|
||||
// still works. Status is success-or-partial, never 500.
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('partial'); // one error
|
||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile'));
|
||||
expect(erroredFile).toBeTruthy();
|
||||
expect(erroredFile.error).toMatch(/forbidden characters or path segments/);
|
||||
|
||||
// The legit logo DID get written.
|
||||
const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png');
|
||||
expect(fs.existsSync(legitPath)).toBe(true);
|
||||
|
||||
// The traversal target was NEVER written.
|
||||
const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile');
|
||||
// Resolve to absolute path — should be outside tmpDir/assets.
|
||||
const resolvedEsc = path.resolve(escapePath);
|
||||
expect(fs.existsSync(resolvedEsc)).toBe(false);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
assets: {
|
||||
'/etc/passwd': Buffer.from('evil').toString('base64'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('partial');
|
||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd'));
|
||||
expect(erroredFile).toBeTruthy();
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
themes: {
|
||||
'../../../etc/caddy/evil.json': { evil: true },
|
||||
'legit-theme.json': { ok: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('partial');
|
||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json'));
|
||||
expect(erroredFile).toBeTruthy();
|
||||
expect(erroredFile.error).toMatch(/must match/);
|
||||
|
||||
// The legit theme DID get written.
|
||||
expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true);
|
||||
});
|
||||
|
||||
it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => {
|
||||
const app = createDisasterApp({ dataDir: tmpDir });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/disaster/restore')
|
||||
.send({
|
||||
version: '1.0',
|
||||
themes: {
|
||||
'no-extension': { ok: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('partial');
|
||||
const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension'));
|
||||
expect(erroredFile).toBeTruthy();
|
||||
expect(erroredFile.error).toMatch(/must match/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* DC-059: disk-space POST /config threshold-ordering invariant.
|
||||
*
|
||||
* DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
|
||||
* budget usage crosses, in the order
|
||||
* cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
|
||||
* If a caller writes the three thresholds out of order
|
||||
* (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
|
||||
* priority branches become unreachable and the monitor silently
|
||||
* misclassifies budget state — 'warning' would never fire even though the
|
||||
* user set it as a threshold they care about.
|
||||
*
|
||||
* The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()`
|
||||
* helper validates the *effective* (merged with live baseline) config
|
||||
* against the invariant `warningThresholdPct < criticalThresholdPct <
|
||||
* cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig.
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. Monotonic ascending order is accepted (happy path).
|
||||
* 2. warningThresholdPct >= criticalThresholdPct is rejected with 400.
|
||||
* 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400.
|
||||
* 4. Partial updates work one field at a time without violating the
|
||||
* invariant against the current baseline.
|
||||
* 5. Out-of-bounds numeric values are clamped to the same bounds the
|
||||
* original inline Math.min/Math.max chains enforced (50/60/70 → 99).
|
||||
* 6. DiskSpaceMonitor.configure is NEVER called when the request is
|
||||
* rejected (no partial mutation).
|
||||
* 7. The merged config returned to the client is the post-clamp value,
|
||||
* not the raw request body.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: true,
|
||||
diskBudgetGB: 10,
|
||||
warningThresholdPct: 80,
|
||||
criticalThresholdPct: 90,
|
||||
autoCleanup: true,
|
||||
cleanupAggressivePct: 95,
|
||||
};
|
||||
|
||||
function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) {
|
||||
const state = { ...initial };
|
||||
return {
|
||||
configure: jest.fn((updates) => {
|
||||
Object.assign(state, updates);
|
||||
return { ...state };
|
||||
}),
|
||||
getConfig: jest.fn(() => ({ ...state })),
|
||||
getSnapshot: jest.fn(async () => ({})),
|
||||
getDetailedBreakdown: jest.fn(async () => ({})),
|
||||
performCleanup: jest.fn(async () => ({})),
|
||||
// Test-only: peek at the internal state to confirm no mutation on rejection
|
||||
_state: state,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRouter(monitor) {
|
||||
// Reset module cache so each test starts fresh
|
||||
jest.resetModules();
|
||||
const mod = require('../../routes/disk-space');
|
||||
return mod({
|
||||
diskSpaceMonitor: monitor,
|
||||
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
});
|
||||
}
|
||||
|
||||
function buildApp(router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => { next(); }); // strip auth
|
||||
app.use('/', router);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || err.status || 500;
|
||||
res.status(status).json({
|
||||
error: err.message,
|
||||
code: err.code || 'ERR',
|
||||
field: err.field || null,
|
||||
});
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
function supertestFetch(app) {
|
||||
return function (method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const { port } = server.address();
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const req = http.request({
|
||||
method,
|
||||
hostname: '127.0.0.1',
|
||||
port,
|
||||
path,
|
||||
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
||||
}, (res) => {
|
||||
let chunks = '';
|
||||
res.on('data', (c) => { chunks += c; });
|
||||
res.on('end', () => {
|
||||
server.close();
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => { server.close(); reject(e); });
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => {
|
||||
let monitor, app, fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
monitor = buildFakeDiskSpaceMonitor();
|
||||
const router = buildRouter(monitor);
|
||||
app = buildApp(router);
|
||||
fetch = supertestFetch(app);
|
||||
});
|
||||
|
||||
test('happy path — strict monotonic ascending order is accepted', async () => {
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 75,
|
||||
criticalThresholdPct: 88,
|
||||
cleanupAggressivePct: 95,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config).toEqual(expect.objectContaining({
|
||||
warningThresholdPct: 75,
|
||||
criticalThresholdPct: 88,
|
||||
cleanupAggressivePct: 95,
|
||||
}));
|
||||
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => {
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 95,
|
||||
criticalThresholdPct: 80,
|
||||
cleanupAggressivePct: 99,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
||||
expect(res.body.field).toBe('warningThresholdPct');
|
||||
// Critical invariant: monitor.configure was NEVER called.
|
||||
expect(monitor.configure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => {
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 60,
|
||||
criticalThresholdPct: 95,
|
||||
cleanupAggressivePct: 80,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/);
|
||||
expect(res.body.field).toBe('criticalThresholdPct');
|
||||
expect(monitor.configure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('equal thresholds are rejected (strict <, not <=)', async () => {
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 80,
|
||||
criticalThresholdPct: 80,
|
||||
cleanupAggressivePct: 90,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(monitor.configure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('partial update — single field accepted against existing baseline', async () => {
|
||||
// Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85.
|
||||
const res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.warningThresholdPct).toBe(85);
|
||||
expect(res.body.config.criticalThresholdPct).toBe(90);
|
||||
expect(res.body.config.cleanupAggressivePct).toBe(95);
|
||||
});
|
||||
|
||||
test('partial update — would violate invariant against baseline, rejected', async () => {
|
||||
// Defaults: warning=80, critical=90, aggressive=95. Setting warning=95
|
||||
// would collide with the existing critical=90 (warning >= critical).
|
||||
const res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
||||
expect(monitor.configure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('partial update — succeeds after baseline was updated in a prior request', async () => {
|
||||
// First request: bump warning from 80 → 85 (within current critical=90).
|
||||
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||
expect(res.status).toBe(200);
|
||||
// Second request: now bump warning from 85 → 89. Still under critical=90.
|
||||
res = await fetch('POST', '/config', { warningThresholdPct: 89 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(monitor.configure).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('partial update — would violate against the NEW baseline, rejected', async () => {
|
||||
// Step 1: raise warning to 85.
|
||||
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||
expect(res.status).toBe(200);
|
||||
// Step 2: try to raise warning to 95 — would collide with critical=90.
|
||||
res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
||||
expect(res.status).toBe(400);
|
||||
// monitor.configure should have run exactly once (the accepted request).
|
||||
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('out-of-bounds values are clamped to documented ranges', async () => {
|
||||
// Note: the three values must produce a valid monotonic ordering AFTER
|
||||
// clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70
|
||||
// would produce critical=99 > aggressive=70 which is rejected by the
|
||||
// ordering check. Use values that clamp into a valid range.
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 20, // below warning min 50 → clamped to 50
|
||||
criticalThresholdPct: 85, // valid
|
||||
cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config).toEqual(expect.objectContaining({
|
||||
warningThresholdPct: 50,
|
||||
criticalThresholdPct: 85,
|
||||
cleanupAggressivePct: 99,
|
||||
}));
|
||||
});
|
||||
|
||||
test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => {
|
||||
// Strings are not numbers → unchanged from baseline. Confirms the
|
||||
// ordering check doesn\'t reject legitimate "I didn\'t change this" requests.
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: '80',
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged
|
||||
expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates
|
||||
});
|
||||
|
||||
test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => {
|
||||
const res = await fetch('POST', '/config', {
|
||||
diskBudgetGB: 50,
|
||||
autoCleanup: false,
|
||||
warningThresholdPct: 81,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.diskBudgetGB).toBe(50);
|
||||
expect(res.body.config.autoCleanup).toBe(false);
|
||||
expect(res.body.config.warningThresholdPct).toBe(81);
|
||||
});
|
||||
|
||||
test('rejected request does NOT mutate the live diskConfig', async () => {
|
||||
const before = { ...monitor._state };
|
||||
const res = await fetch('POST', '/config', {
|
||||
warningThresholdPct: 95, // collides with critical=90
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(monitor._state).toEqual(before);
|
||||
});
|
||||
|
||||
test('POST /config with no thresholds in body is a no-op against baseline', async () => {
|
||||
const res = await fetch('POST', '/config', { diskBudgetGB: 25 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.diskBudgetGB).toBe(25);
|
||||
expect(res.body.config.warningThresholdPct).toBe(80); // unchanged
|
||||
expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged
|
||||
expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged
|
||||
});
|
||||
});
|
||||
@@ -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,535 @@
|
||||
/**
|
||||
* DC-074: SSRF hardening for sites.js — `/site` and `/site/external`
|
||||
* must reject upstream hosts that resolve to private/reserved ranges
|
||||
* BEFORE they reach the Caddyfile.
|
||||
*
|
||||
* Bug class: an authenticated dashboard operator could call
|
||||
* POST /api/v1/site {domain: "x.example.com", upstream: "10.0.0.1:80"}
|
||||
* POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"}
|
||||
* and end up with a Caddy site block that proxies PUBLIC traffic to an
|
||||
* INTERNAL host. Caddy runs on DNS2 (same network as the targets), so
|
||||
* the SSRF lands.
|
||||
*
|
||||
* Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`,
|
||||
* which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS
|
||||
* metadata IP) with no problem. `/site/external` used `validateURL`
|
||||
* without `blockPrivate: true` at all.
|
||||
*
|
||||
* Post-fix: a new helper `validateUpstream()` in `fleet-validation.js`
|
||||
* reuses the resolver+private-range checks fleet-validation already has
|
||||
* for DC-068, gating Caddyfile writes behind a public-IP requirement.
|
||||
* Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who
|
||||
* intentionally proxy to private targets.
|
||||
*
|
||||
* The suite covers three layers:
|
||||
* 1. Helper unit tests — validateUpstream with mocked DNS / literal IPs
|
||||
* 2. Route integration tests — POST /site and POST /site/external
|
||||
* reject each known private range, accept public IPs and hostnames
|
||||
* 3. Regression — pre-fix payload `10.0.0.1:80` is rejected (the
|
||||
* canonical SSRF regression proof)
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const {
|
||||
validateUpstream,
|
||||
isPrivateOrReservedIPv4,
|
||||
isPrivateOrReservedIPv6,
|
||||
} = require('../../src/utilities/fleet-validation');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() });
|
||||
|
||||
/**
|
||||
* Build a minimal Express app that mounts /api/v1/sites with stubbed
|
||||
* caddy/dns/buildDomain/addServiceToConfig. The stubs record every call
|
||||
* so tests can assert the route does NOT mutate the Caddyfile when it
|
||||
* should reject.
|
||||
*/
|
||||
function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
const sites = require('../../routes/sites');
|
||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const caddy = caddyStub || {
|
||||
read: async () => '# stub caddyfile\n',
|
||||
modify: jest.fn(async () => ({ success: true })),
|
||||
adminUrl: 'http://127.0.0.1:2019',
|
||||
filePath: '/tmp/stub-Caddyfile',
|
||||
};
|
||||
const dns = dnsStub || {
|
||||
universalCreateRecord: jest.fn(async () => true),
|
||||
};
|
||||
app.use('/api/v1', sites({
|
||||
asyncHandler: wrap,
|
||||
ok: (res, data) => res.json({ ok: true, ...data }),
|
||||
successMessage: (res, msg) => res.json({ ok: true, message: msg }),
|
||||
caddy,
|
||||
dns,
|
||||
fetchT: async () => ({ ok: true, json: async () => ({}) }),
|
||||
buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`),
|
||||
addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true),
|
||||
siteConfig: { dnsServerIp: '127.0.0.1' },
|
||||
log: log || LOG(),
|
||||
}));
|
||||
// JSON error middleware — must mirror the shape sites.js's production
|
||||
// global error middleware emits so route tests can assert on it. Without
|
||||
// this, Express's default error handler returns an HTML stack trace and
|
||||
// res.body.error is undefined.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({
|
||||
error: err.message || 'Internal Server Error',
|
||||
code: err.code || null,
|
||||
field: err.field || null,
|
||||
});
|
||||
});
|
||||
return { app, caddy };
|
||||
}
|
||||
|
||||
/** Mock dns.promises.lookup to return a specific IP for any hostname.
|
||||
* Returns an array of `{address, family}` records since fleet-validation
|
||||
* calls `dns.lookup(name, {all: true})`. */
|
||||
function mockDnsLookup(map) {
|
||||
const dns = require('dns');
|
||||
const original = dns.promises.lookup;
|
||||
dns.promises.lookup = async (hostname, opts) => {
|
||||
for (const [pattern, ip] of Object.entries(map)) {
|
||||
if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) {
|
||||
const family = ip.includes(':') ? 6 : 4;
|
||||
return [{ address: ip, family }];
|
||||
}
|
||||
}
|
||||
// Default: throw ENOTFOUND
|
||||
const err = new Error('ENOTFOUND');
|
||||
err.code = 'ENOTFOUND';
|
||||
throw err;
|
||||
};
|
||||
return () => {
|
||||
dns.promises.lookup = original;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Helper unit tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-074: validateUpstream (helper)', () => {
|
||||
let restoreDns;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreDns) restoreDns();
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
});
|
||||
|
||||
describe('format validation', () => {
|
||||
test('rejects empty / non-string with INVALID_UPSTREAM', async () => {
|
||||
expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||
expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||
expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||
expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||
});
|
||||
|
||||
test('rejects missing port with INVALID_UPSTREAM', async () => {
|
||||
expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' });
|
||||
});
|
||||
|
||||
test('rejects non-integer port with INVALID_PORT', async () => {
|
||||
expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
});
|
||||
|
||||
test('rejects out-of-range port with INVALID_PORT', async () => {
|
||||
expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('private IPv4 reject (literal)', () => {
|
||||
const PRIVATE_V4 = [
|
||||
['127.0.0.1', 'loopback'],
|
||||
['127.255.255.1', 'loopback'],
|
||||
['10.0.0.1', 'RFC 1918'],
|
||||
['172.16.0.1', 'RFC 1918'],
|
||||
['192.168.1.1', 'RFC 1918'],
|
||||
['169.254.169.254', 'link-local'], // AWS IMDS
|
||||
['100.64.0.1', 'CGNAT'],
|
||||
['224.0.0.1', 'multicast'],
|
||||
['255.255.255.255', 'broadcast'],
|
||||
['0.0.0.0', 'reserved'],
|
||||
];
|
||||
for (const [ip, wantLabel] of PRIVATE_V4) {
|
||||
test(`rejects ${ip} (${wantLabel})`, async () => {
|
||||
const r = await validateUpstream(`${ip}:80`);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
expect(r.message).toMatch(new RegExp(wantLabel, 'i'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('private IPv6 reject (literal)', () => {
|
||||
test('rejects ::1 (loopback)', async () => {
|
||||
const r = await validateUpstream('[::1]:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV6');
|
||||
});
|
||||
|
||||
test('rejects fe80::1 (link-local)', async () => {
|
||||
const r = await validateUpstream('[fe80::1]:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV6');
|
||||
});
|
||||
|
||||
test('rejects fc00::1 (ULA)', async () => {
|
||||
const r = await validateUpstream('[fc00::1]:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public IPs accepted (literal)', () => {
|
||||
test('accepts 8.8.8.8', async () => {
|
||||
const r = await validateUpstream('8.8.8.8:53');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.host).toBe('8.8.8.8');
|
||||
expect(r.port).toBe(53);
|
||||
expect(r.family).toBe(4);
|
||||
});
|
||||
|
||||
test('accepts 1.1.1.1', async () => {
|
||||
const r = await validateUpstream('1.1.1.1:443');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.port).toBe(443);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostname resolve', () => {
|
||||
test('accepts hostname that resolves to public IP', async () => {
|
||||
restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' });
|
||||
const r = await validateUpstream('public.example.com:443');
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.resolvedIp).toBe('8.8.8.8');
|
||||
expect(r.family).toBe(4);
|
||||
});
|
||||
|
||||
test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => {
|
||||
restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' });
|
||||
const r = await validateUpstream('evil.example.com:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/);
|
||||
});
|
||||
|
||||
test('rejects hostname that fails to resolve', async () => {
|
||||
// mockDnsLookup default throws ENOTFOUND
|
||||
const r = await validateUpstream('does-not-exist.invalid:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toMatch(/DNS_/);
|
||||
});
|
||||
|
||||
test('rejects hostname with invalid charset pre-DNS', async () => {
|
||||
const r = await validateUpstream('host with spaces:80');
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('INVALID_HOST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => {
|
||||
test('default rejects private IPs', async () => {
|
||||
const r = await validateUpstream('10.0.0.1:80');
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('opt-in accepts private literal IP', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
const r = await validateUpstream('10.0.0.1:80');
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('opt-in accepts private DNS-resolved host', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' });
|
||||
const r = await validateUpstream('internal.example.com:80');
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.code).toBe('PRIVATE_IPV4');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Route integration tests — POST /site
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-074: POST /api/v1/site — SSRF hardening', () => {
|
||||
let restoreDns;
|
||||
let caddyStub;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
caddyStub = {
|
||||
read: async () => '# stub caddyfile\n',
|
||||
modify: jest.fn(async () => ({ success: true })),
|
||||
adminUrl: 'http://127.0.0.1:2019',
|
||||
filePath: '/tmp/stub-Caddyfile',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreDns) restoreDns();
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
});
|
||||
|
||||
const REGRESSION_CASES = [
|
||||
['10.0.0.1:80', 'PRIVATE_IPV4'],
|
||||
['172.16.0.1:80', 'PRIVATE_IPV4'],
|
||||
['192.168.1.1:80', 'PRIVATE_IPV4'],
|
||||
['127.0.0.1:80', 'PRIVATE_IPV4'],
|
||||
['169.254.169.254:80', 'PRIVATE_IPV4'], // AWS IMDS
|
||||
['100.64.0.1:80', 'PRIVATE_IPV4'], // CGNAT
|
||||
['224.0.0.1:80', 'PRIVATE_IPV4'], // multicast
|
||||
['0.0.0.0:80', 'PRIVATE_IPV4'], // reserved
|
||||
['[::1]:80', 'PRIVATE_IPV6'],
|
||||
['[fc00::1]:80', 'PRIVATE_IPV6'],
|
||||
];
|
||||
|
||||
for (const [upstream, wantCode] of REGRESSION_CASES) {
|
||||
test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'evil.example.com', upstream });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/\[DC-074\]/);
|
||||
expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/);
|
||||
// caddy.modify() must NOT have been called (gate happens before write)
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects DNS-resolved private IP (rebinding defense)', async () => {
|
||||
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/10\.0\.0\.5/);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('accepts public literal IP', async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'new.example.com', upstream: '8.8.8.8:80' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('accepts hostname resolving to public IP', async () => {
|
||||
restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' });
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'new.example.com', upstream: 'real.example.com:80' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' });
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'lab.example.com', upstream: 'internal.lan:80' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('rejects out-of-range port without invoking private-IP check', async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects upstream with spaces (charset) without invoking private-IP check', async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'new.example.com', upstream: 'not a host:80' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Route integration tests — POST /site/external
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => {
|
||||
let restoreDns;
|
||||
let caddyStub;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
caddyStub = {
|
||||
read: async () => '# stub caddyfile\n',
|
||||
modify: jest.fn(async () => ({ success: true })),
|
||||
adminUrl: 'http://127.0.0.1:2019',
|
||||
filePath: '/tmp/stub-Caddyfile',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (restoreDns) restoreDns();
|
||||
delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS;
|
||||
});
|
||||
|
||||
const REGRESSION_CASES = [
|
||||
'http://10.0.0.1',
|
||||
'http://192.168.1.1',
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.169.254', // AWS IMDS via URL form
|
||||
'http://100.64.0.1', // CGNAT — caught by validateUpstream defense-in-depth, not validateURL
|
||||
'http://0.0.0.0',
|
||||
'http://[::1]',
|
||||
'http://[fc00::1]',
|
||||
];
|
||||
|
||||
for (const externalUrl of REGRESSION_CASES) {
|
||||
test(`rejects externalUrl="${externalUrl}"`, async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl });
|
||||
// 400 from validateURL OR from validateUpstream — either path closes the gate.
|
||||
expect(res.status).toBe(400);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects DNS-resolved private IP', async () => {
|
||||
restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' });
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('accepts externalUrl with public hostname', async () => {
|
||||
restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' });
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl: 'http://api.example.com' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(caddyStub.modify).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('accepts externalUrl with public literal IP', async () => {
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => {
|
||||
process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true';
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' });
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Regression — pre-fix payload (the canonical SSRF regression proof)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-074: regression — pre-fix payloads are now rejected', () => {
|
||||
test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => {
|
||||
const caddyStub = {
|
||||
read: async () => '',
|
||||
modify: jest.fn(async () => ({ success: true })),
|
||||
adminUrl: 'http://127.0.0.1:2019',
|
||||
filePath: '/tmp/stub-Caddyfile',
|
||||
};
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site')
|
||||
.send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' });
|
||||
expect(res.status).toBe(400);
|
||||
// Pre-fix this payload would have been accepted, the regex happily
|
||||
// matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy
|
||||
// site block would have been written that proxied public HTTPS
|
||||
// traffic at `evil.attacker.com` to the internal 10.0.0.1:80.
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => {
|
||||
const caddyStub = {
|
||||
read: async () => '',
|
||||
modify: jest.fn(async () => ({ success: true })),
|
||||
adminUrl: 'http://127.0.0.1:2019',
|
||||
filePath: '/tmp/stub-Caddyfile',
|
||||
};
|
||||
const { app } = createSitesApp({ caddyStub });
|
||||
const res = await request(app)
|
||||
.post('/api/v1/site/external')
|
||||
.send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(caddyStub.modify).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Sanity — fleet-validation helper exports still work as before
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => {
|
||||
test('isPrivateOrReservedIPv4 still detects the same set as before', () => {
|
||||
expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true);
|
||||
expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false);
|
||||
});
|
||||
|
||||
test('isPrivateOrReservedIPv6 still detects the same set as before', () => {
|
||||
expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true);
|
||||
expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -131,6 +131,20 @@ describe('routes/tailscale-admin: PUT /settings', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('400 on apiToken exceeding 256-char length cap (DC-080)', async () => {
|
||||
const { app } = createApp();
|
||||
const oversized = 'tskey-api-' + 'x'.repeat(300); // > 256 chars
|
||||
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: oversized });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error || res.body.message).toMatch(/exceeds maximum length/i);
|
||||
});
|
||||
|
||||
test('400 on non-string apiToken (DC-080)', async () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).put('/api/v1/tailscale/settings').send({ apiToken: 12345 });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('200 + saves token + writes metadata on valid token', async () => {
|
||||
const fakeClient = makeFakeClient({
|
||||
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
||||
@@ -293,6 +307,76 @@ describe('routes/tailscale-admin: POST /settings/test', () => {
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
||||
});
|
||||
|
||||
test('400 on body.apiToken not starting with tskey-api- (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: false }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tailscale/settings/test')
|
||||
.send({ apiToken: 'arbitrary-junk' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('400 on body.apiToken exceeding length cap (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: false }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const oversized = 'tskey-api-' + 'x'.repeat(300);
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tailscale/settings/test')
|
||||
.send({ apiToken: oversized });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fakeClient.setApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('omitting apiToken is allowed (uses stored token path) (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient({ ping: jest.fn(async () => ({ domain: 'stored.ts.net' })) });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: true }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const res = await request(app)
|
||||
.post('/api/v1/tailscale/settings/test')
|
||||
.send({}); // no apiToken in body
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
||||
@@ -511,6 +595,99 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('POST /admin/keys rejects null/123/object tags entries (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: true }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
// Mixed: null, number, object — all must be rejected
|
||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['tag:guest', null, 123, { x: 1 }] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('POST /admin/keys rejects uppercase / whitespace / CRLF in tags (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: true }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ tags: ['TAG:guest', 'tag:foo bar', 'tag:x\r\ninjection'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('POST /admin/keys rejects description exceeding 120 chars (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: true }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const longDesc = 'a'.repeat(200); // > 120 chars
|
||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({ description: longDesc });
|
||||
expect(res.status).toBe(400);
|
||||
expect(fakeClient.createAuthKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('POST /admin/keys accepts canonical lowercase tag: form (DC-080)', async () => {
|
||||
const fakeClient = makeFakeClient({
|
||||
createAuthKey: jest.fn(async (opts) => ({ id: 'k2', key: 'tskey-secret-2', ...opts })),
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const routes = require('../../routes/tailscale-admin');
|
||||
const tailscaleCoord = {
|
||||
loadMetadata: () => ({ configured: true }),
|
||||
saveMetadata: jest.fn(),
|
||||
setApiToken: jest.fn(),
|
||||
getClient: jest.fn(async () => fakeClient),
|
||||
hasApiToken: jest.fn(),
|
||||
};
|
||||
app.use('/api/v1/tailscale', routes({
|
||||
tailscaleCoord, asyncHandler,
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
const res = await request(app).post('/api/v1/tailscale/admin/keys').send({
|
||||
tags: ['tag:guest-plex', 'tag:server'],
|
||||
expirySeconds: 86400,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(fakeClient.createAuthKey).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tags: ['tag:guest-plex', 'tag:server'],
|
||||
}));
|
||||
});
|
||||
|
||||
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
||||
const fakeClient = makeFakeClient();
|
||||
const app = express();
|
||||
@@ -572,4 +749,110 @@ describe('routes/tailscale-admin: security boundary', () => {
|
||||
await request(app).delete('/api/v1/tailscale/settings');
|
||||
expect(stored.token).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
// DC-080 direct validator unit tests (no supertest, no Express)
|
||||
describe('routes/tailscale-admin: DC-080 validators (direct)', () => {
|
||||
const { _validators } = require('../../routes/tailscale-admin');
|
||||
const {
|
||||
validateApiToken,
|
||||
validateTags,
|
||||
validateDescription,
|
||||
TAILSCALE_TOKEN_PREFIX,
|
||||
TAILSCALE_TOKEN_MAX_LEN,
|
||||
DESCRIPTION_MAX_LEN,
|
||||
} = _validators;
|
||||
|
||||
describe('validateApiToken', () => {
|
||||
test('accepts canonical tskey-api-...', () => {
|
||||
expect(validateApiToken('tskey-api-abc123')).toBeNull();
|
||||
});
|
||||
test('rejects empty', () => {
|
||||
expect(validateApiToken('')).toMatch(/required/);
|
||||
});
|
||||
test('rejects undefined / null', () => {
|
||||
expect(validateApiToken(undefined)).toMatch(/required/);
|
||||
expect(validateApiToken(null)).toMatch(/required/);
|
||||
});
|
||||
test('rejects non-string (number, object, array)', () => {
|
||||
expect(validateApiToken(123)).toMatch(/must be a string/);
|
||||
expect(validateApiToken({})).toMatch(/must be a string/);
|
||||
expect(validateApiToken(['x'])).toMatch(/must be a string/);
|
||||
});
|
||||
test('rejects wrong prefix', () => {
|
||||
expect(validateApiToken('not-a-token')).toMatch(/must start with/);
|
||||
});
|
||||
test('accepts exactly at length cap', () => {
|
||||
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length);
|
||||
expect(validateApiToken(token)).toBeNull();
|
||||
});
|
||||
test('rejects 1 over length cap', () => {
|
||||
const token = 'tskey-api-' + 'x'.repeat(TAILSCALE_TOKEN_MAX_LEN - 'tskey-api-'.length + 1);
|
||||
expect(validateApiToken(token)).toMatch(/exceeds maximum length/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTags', () => {
|
||||
test('accepts undefined / null (optional)', () => {
|
||||
expect(validateTags(undefined)).toBeNull();
|
||||
expect(validateTags(null)).toBeNull();
|
||||
});
|
||||
test('rejects non-array', () => {
|
||||
expect(validateTags('tag:foo')).toMatch(/must be an array/);
|
||||
expect(validateTags({})).toMatch(/must be an array/);
|
||||
});
|
||||
test('rejects entries that are not strings', () => {
|
||||
expect(validateTags(['tag:a', null])).toMatch(/tags\[1\]/);
|
||||
expect(validateTags(['tag:a', 123])).toMatch(/tags\[1\]/);
|
||||
expect(validateTags(['tag:a', {}])).toMatch(/tags\[1\]/);
|
||||
});
|
||||
test('rejects uppercase / whitespace / CRLF', () => {
|
||||
expect(validateTags(['TAG:foo'])).toMatch(/tags\[0\]/);
|
||||
expect(validateTags(['tag:foo bar'])).toMatch(/tags\[0\]/);
|
||||
expect(validateTags(['tag:foo\r\nbar'])).toMatch(/tags\[0\]/);
|
||||
});
|
||||
test('rejects entries starting with non-alnum (no leading colon)', () => {
|
||||
expect(validateTags([':foo'])).toMatch(/tags\[0\]/);
|
||||
});
|
||||
|
||||
test('rejects bare "tag:" with empty name (Tailscale spec violation) (DC-080 round-2)', () => {
|
||||
expect(validateTags(['tag:'])).toMatch(/tags\[0\]/);
|
||||
});
|
||||
|
||||
test('rejects colon-only chars after tag: prefix (DC-080 round-2)', () => {
|
||||
expect(validateTags(['tag:::'])).toMatch(/tags\[0\]/);
|
||||
expect(validateTags(['tag:---'])).toMatch(/tags\[0\]/);
|
||||
});
|
||||
test('accepts canonical tag:server form', () => {
|
||||
expect(validateTags(['tag:server'])).toBeNull();
|
||||
expect(validateTags(['tag:guest-plex', 'tag:server'])).toBeNull();
|
||||
});
|
||||
test('rejects empty array entry', () => {
|
||||
expect(validateTags(['tag:a', ''])).toMatch(/tags\[1\]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateDescription', () => {
|
||||
test('accepts undefined / null', () => {
|
||||
expect(validateDescription(undefined)).toBeNull();
|
||||
expect(validateDescription(null)).toBeNull();
|
||||
});
|
||||
test('rejects non-string', () => {
|
||||
expect(validateDescription(123)).toMatch(/must be a string/);
|
||||
});
|
||||
test('rejects over 120 chars', () => {
|
||||
const long = 'a'.repeat(DESCRIPTION_MAX_LEN + 1);
|
||||
expect(validateDescription(long)).toMatch(/exceeds maximum length/);
|
||||
});
|
||||
test('accepts at the cap', () => {
|
||||
const exact = 'a'.repeat(DESCRIPTION_MAX_LEN);
|
||||
expect(validateDescription(exact)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('exports surface stays in sync with constants used inside validators', () => {
|
||||
// Guard against drift: if a future refactor renames a constant, this fails
|
||||
expect(TAILSCALE_TOKEN_PREFIX).toBe('tskey-api-');
|
||||
expect(typeof TAILSCALE_TOKEN_MAX_LEN).toBe('number');
|
||||
expect(typeof DESCRIPTION_MAX_LEN).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,239 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DC-078: registry digest probe reliability hardening ──────────────────
|
||||
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
|
||||
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
|
||||
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
|
||||
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
|
||||
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
|
||||
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
|
||||
describe('DC-078 registry reliability', () => {
|
||||
// Use real timers — fetchWithReliability's retry uses setTimeout for
|
||||
// backoff, which jest's fake timers would block indefinitely.
|
||||
beforeEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||
});
|
||||
|
||||
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
|
||||
let capturedOptions = null;
|
||||
const req = {
|
||||
on: jest.fn(),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
capturedOptions = options;
|
||||
// Return a 200 immediately so the promise resolves cleanly.
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
return req;
|
||||
});
|
||||
|
||||
await updateManager._httpsRequestOnce({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: '/v2/library/nginx/manifests/latest',
|
||||
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
|
||||
maxBodyBytes: 65536,
|
||||
});
|
||||
expect(capturedOptions).not.toBeNull();
|
||||
expect(capturedOptions.family).toBe(4);
|
||||
expect(capturedOptions.timeout).toBeGreaterThan(0);
|
||||
expect(capturedOptions.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
|
||||
let attempts = 0;
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
// First attempt: emit ETIMEDOUT via the request 'error' event
|
||||
const reqErr = new Error('request timeout');
|
||||
reqErr.code = 'ETIMEDOUT';
|
||||
const req = {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return req;
|
||||
}
|
||||
// Second attempt: 200 OK with a digest header
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:abc123def456' },
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||
});
|
||||
|
||||
const result = await updateManager.fetchWithReliability({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: '/v2/library/nginx/manifests/latest',
|
||||
});
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
|
||||
});
|
||||
|
||||
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
|
||||
let attempts = 0;
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
attempts += 1;
|
||||
const res = {
|
||||
statusCode: 500,
|
||||
headers: {},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||
});
|
||||
const result = await updateManager.fetchWithReliability({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: '/v2/library/nginx/manifests/latest',
|
||||
});
|
||||
expect(attempts).toBe(1);
|
||||
expect(result.statusCode).toBe(500);
|
||||
});
|
||||
|
||||
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
|
||||
let attempts = 0;
|
||||
https.request.mockImplementation(() => {
|
||||
attempts += 1;
|
||||
const reqErr = new Error('connect ETIMEDOUT');
|
||||
reqErr.code = 'ETIMEDOUT';
|
||||
const req = {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return req;
|
||||
});
|
||||
await expect(updateManager.fetchWithReliability({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: '/v2/library/nginx/manifests/latest',
|
||||
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
|
||||
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
|
||||
expect(attempts).toBe(1 + 1);
|
||||
});
|
||||
|
||||
it('getDockerHubDigest returns digest on 200', async () => {
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||
});
|
||||
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||
expect(digest).toBe('sha256:hubdigest9999');
|
||||
});
|
||||
|
||||
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
|
||||
let calls = 0;
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
|
||||
const res = {
|
||||
statusCode: 401,
|
||||
headers: {
|
||||
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
|
||||
},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
} else if (calls === 2) {
|
||||
// Second call: auth.example.com returns the token JSON
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
} else {
|
||||
// Third call: registry-1.docker.io with Bearer header returns the digest
|
||||
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
}
|
||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||
});
|
||||
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
|
||||
expect(digest).toBe('sha256:autheddigest7777');
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
|
||||
it('getGhcrDigest returns digest on 200', async () => {
|
||||
https.request.mockImplementation((options, cb) => {
|
||||
expect(options.hostname).toBe('ghcr.io');
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
}),
|
||||
};
|
||||
setImmediate(() => cb(res));
|
||||
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
|
||||
});
|
||||
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
|
||||
expect(digest).toBe('sha256:ghcrdigest1234');
|
||||
});
|
||||
|
||||
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
|
||||
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
|
||||
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
|
||||
// catch arm, which logs and returns null (matches old behavior).
|
||||
https.request.mockImplementation(() => {
|
||||
const reqErr = new Error('connect ETIMEDOUT');
|
||||
reqErr.code = 'ETIMEDOUT';
|
||||
const req = {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'error') setImmediate(() => handler(reqErr));
|
||||
}),
|
||||
end: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
};
|
||||
return req;
|
||||
});
|
||||
const digest = await updateManager.getLatestImageDigest('nginx:latest');
|
||||
expect(digest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAuthHeader', () => {
|
||||
it('parses Docker Hub Bearer auth header', () => {
|
||||
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
|
||||
@@ -481,7 +714,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
setImmediate(() => cb({
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:fromregistry' },
|
||||
on: jest.fn()
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
})
|
||||
}));
|
||||
return { on: jest.fn(), end: jest.fn() };
|
||||
});
|
||||
@@ -495,7 +730,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
setImmediate(() => cb({
|
||||
statusCode: 401,
|
||||
headers: {},
|
||||
on: jest.fn()
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
})
|
||||
}));
|
||||
return { on: jest.fn(), end: jest.fn() };
|
||||
});
|
||||
@@ -504,6 +741,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
});
|
||||
|
||||
it('rejects on https request error', async () => {
|
||||
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
|
||||
// Use a non-transient code (or no code) for the test to propagate.
|
||||
jest.useRealTimers();
|
||||
https.request.mockImplementation(() => {
|
||||
const req = { on: jest.fn(), end: jest.fn() };
|
||||
// Trigger error event asynchronously
|
||||
@@ -516,6 +756,7 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
|
||||
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
|
||||
.rejects.toThrow('connection refused');
|
||||
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
|
||||
});
|
||||
|
||||
it('normalizes library/ prefix for official images', async () => {
|
||||
@@ -525,7 +766,9 @@ describe('UpdateManager — Docker image update lifecycle', () => {
|
||||
setImmediate(() => cb({
|
||||
statusCode: 200,
|
||||
headers: { 'docker-content-digest': 'sha256:digest' },
|
||||
on: jest.fn()
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'end') setImmediate(handler);
|
||||
})
|
||||
}));
|
||||
return { on: jest.fn(), end: jest.fn() };
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,17 +123,106 @@ module.exports = function(ctx) {
|
||||
res.send(script);
|
||||
}, 'ca-install-script'));
|
||||
|
||||
// DC-076: per-service cert/key download — TOTP + admin scope required.
|
||||
// Pre-fix this endpoint (a) had a hardcoded `password = 'dashcaddy'` default
|
||||
// for the PFX format — a default credential published in source; (b) was
|
||||
// public-listed in middleware.js PUBLIC_ROUTES (TOTP bypassed when TOTP is
|
||||
// disabled — single ops command or fresh-install setup state), and (c)
|
||||
// accepted ANY TOTP-authenticated scope (read scope was enough to pull
|
||||
// private keys). Fix: require explicit password (no default), require
|
||||
// TOTP/session (dropped from PUBLIC_ROUTES — see middleware.js), and
|
||||
// require `admin` scope at the route layer as defense-in-depth against
|
||||
// future middleware-ordering mistakes.
|
||||
const CA_CERT_DOMAINS_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
||||
// Per-DC-076: PFX password now required, ≥ 8 chars, no `=` (pkcs12
|
||||
// interprets `=` as a base64 padding marker that downstream tooling
|
||||
// can mis-handle; reject it to keep the password copy-paste-safe).
|
||||
const CA_PFX_PASSWORD_RE = /^[A-Za-z0-9!@#%^_+,.~:-]{8,64}$/;
|
||||
const CA_CERT_RATE_LIMIT = { windowMs: 60_000, max: 10 };
|
||||
const caCertRateBuckets = new Map(); // ip -> { count, resetAt }
|
||||
function caCertRateLimit(ip) {
|
||||
const now = Date.now();
|
||||
const b = caCertRateBuckets.get(ip);
|
||||
if (!b || b.resetAt <= now) {
|
||||
caCertRateBuckets.set(ip, { count: 1, resetAt: now + CA_CERT_RATE_LIMIT.windowMs });
|
||||
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - 1 };
|
||||
}
|
||||
if (b.count >= CA_CERT_RATE_LIMIT.max) {
|
||||
return { allowed: false, remaining: 0, retryAfterMs: b.resetAt - now };
|
||||
}
|
||||
b.count += 1;
|
||||
return { allowed: true, remaining: CA_CERT_RATE_LIMIT.max - b.count };
|
||||
}
|
||||
function requireCaCertAdminScope(req, res) {
|
||||
// TOTP is enforced by `totpAuthMiddleware` globally. Here we additionally
|
||||
// require the `admin` scope — even a read-scope API key or read-scope
|
||||
// JWT must NOT be able to pull a private key. Auth context is mounted on
|
||||
// `req.auth` by the upstream middlewares.
|
||||
const auth = req.auth || {};
|
||||
const scope = Array.isArray(auth.scope) ? auth.scope : [];
|
||||
if (!scope.includes('admin')) {
|
||||
ctx.errorResponse(res, 403,
|
||||
'Admin scope required to download per-service private keys. Re-authenticate with an admin-scoped credential.',
|
||||
{ code: 'DC-076_INSUFFICIENT_SCOPE', requiredScope: 'admin', actualScope: scope });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Generate and download SSL certificate for a service
|
||||
router.get('/cert/:domain', ctx.asyncHandler(async (req, res) => {
|
||||
const { domain } = req.params;
|
||||
const { password = 'dashcaddy', format = 'pfx' } = req.query;
|
||||
if (!requireCaCertAdminScope(req, res)) return;
|
||||
|
||||
if (!/^[a-zA-Z0-9!@#%^_+=,.:-]{1,64}$/.test(password)) {
|
||||
throw new ValidationError('Invalid password. Use only letters, numbers, and basic symbols (max 64 chars).');
|
||||
const { domain } = req.params;
|
||||
|
||||
// DC-076: password is REQUIRED for the pfx format (no `=`) and must
|
||||
// be ≥ 8 chars. Previously `password = 'dashcaddy'` — a hardcoded
|
||||
// default that silently signed every PFX with the same published
|
||||
// password. Other formats (key, pem, crt, fullchain) do not need a
|
||||
// password and ignore the param.
|
||||
const wantsPfx = !req.query.format || req.query.format === 'pfx';
|
||||
let password = req.query.password;
|
||||
if (wantsPfx) {
|
||||
if (typeof password !== 'string' || password === '') {
|
||||
return ctx.errorResponse(res, 400,
|
||||
'PFX format requires an explicit `password` query param (8-64 chars, no `=`). '
|
||||
+ 'A published default is unsafe — pick your own.',
|
||||
{ code: 'DC-076_PASSWORD_REQUIRED' });
|
||||
}
|
||||
if (!CA_PFX_PASSWORD_RE.test(password)) {
|
||||
return ctx.errorResponse(res, 400,
|
||||
'PFX password must be 8-64 chars from [A-Za-z0-9!@#%^_+,.~:-].',
|
||||
{ code: 'DC-076_PASSWORD_INVALID' });
|
||||
}
|
||||
} else {
|
||||
// For non-PFX formats, still reject `=` in the password so a copy-paste
|
||||
// mistake can't accidentally inject a base64 padding token into a path
|
||||
// someone else might log.
|
||||
if (password !== undefined && (typeof password !== 'string' || password.includes('='))) {
|
||||
return ctx.errorResponse(res, 400, 'password (if supplied) must be a string without `=`.',
|
||||
{ code: 'DC-076_PASSWORD_INVALID' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!domain || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(domain)) {
|
||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`);
|
||||
// DC-076: per-IP rate limit — each cert request forks an `openssl` process
|
||||
// and writes to disk. An authenticated admin polling the endpoint in a
|
||||
// loop could exhaust CPU/IO. 10 req/min/IP is enough for normal use
|
||||
// (regenerate one cert, check 4 formats, done) and tight enough to stop
|
||||
// a runaway client.
|
||||
const clientIp = req.ip || req.connection?.remoteAddress || 'unknown';
|
||||
const rl = caCertRateLimit(clientIp);
|
||||
if (!rl.allowed) {
|
||||
res.setHeader('Retry-After', Math.ceil(rl.retryAfterMs / 1000));
|
||||
return ctx.errorResponse(res, 429,
|
||||
`Rate limit exceeded for /api/v1/ca/cert/* (${CA_CERT_RATE_LIMIT.max} req/${CA_CERT_RATE_LIMIT.windowMs/1000}s per IP). Retry in ${Math.ceil(rl.retryAfterMs / 1000)}s.`,
|
||||
{ code: 'DC-076_RATE_LIMITED', retryAfterMs: rl.retryAfterMs });
|
||||
}
|
||||
res.setHeader('X-RateLimit-Limit', String(CA_CERT_RATE_LIMIT.max));
|
||||
res.setHeader('X-RateLimit-Remaining', String(rl.remaining));
|
||||
|
||||
if (!CA_CERT_DOMAINS_RE.test(domain)) {
|
||||
return ctx.errorResponse(res, 400, `Invalid domain name. Must be a valid hostname (e.g., dns1${ctx.siteConfig.tld})`,
|
||||
{ code: 'DC-076_DOMAIN_INVALID' });
|
||||
}
|
||||
|
||||
const pkiPath = platformPaths.pkiDir;
|
||||
@@ -240,8 +329,9 @@ ${safeDomain.includes('.') ? `DNS.2 = *.${safeDomain}` : ''}`;
|
||||
}
|
||||
}, 'ca-cert'));
|
||||
|
||||
// List generated certificates
|
||||
// List generated certificates (DC-076: TOTP-gated; previously public-listed)
|
||||
router.get('/certs', ctx.asyncHandler(async (req, res) => {
|
||||
if (!requireCaCertAdminScope(req, res)) return;
|
||||
const certsDir = platformPaths.generatedCertsDir;
|
||||
|
||||
if (!await exists(certsDir)) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,81 @@ const BACKUP_FILES = [
|
||||
|
||||
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
||||
|
||||
// DC-079: Restrict restored assets to the hardcoded ASSET_FILES allowlist.
|
||||
// The asset KEYS in the snapshot are user-controlled JSON, so iterating
|
||||
// `Object.entries(snapshot.assets)` and writing each name verbatim into
|
||||
// `path.join(assetsDir, name)` lets an attacker POST `{assets: {"../../etc/caddy/Caddyfile":
|
||||
// "<base64-evil>"}}` and overwrite the live Caddyfile via the bind-mount
|
||||
// (path.join('/app/data/assets', '../../etc/caddy/Caddyfile') resolves
|
||||
// to /etc/caddy/Caddyfile). This bypasses the caddyfile-staging gate
|
||||
// above because the dataDir bind-mount can write to /etc/caddy on the host.
|
||||
const ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/;
|
||||
const ASSET_PATH_TRAVERSAL_RE = /(^|\/)\.\.($|\/)|^\//;
|
||||
|
||||
// DC-079: Caddyfile content safety limits for disaster-recovery restore.
|
||||
// The live Caddyfile on DNS2 is ~17 KB and grows linearly with vhost count.
|
||||
// Express's default JSON body parser limit (1 MB) is the outer gate; this
|
||||
// in-handler cap is defense-in-depth against either a future body-limit
|
||||
// raise or a custom body parser. Cap well below the body-parser ceiling.
|
||||
const MAX_CADDYFILE_BYTES = 512 * 1024; // 512 KiB — 30x the live file, far below 1 MB body limit
|
||||
|
||||
// DC-079: theme filenames must match this pattern. No slashes (no path
|
||||
// traversal), no `..`, must end in `.json`, and only filename-safe chars.
|
||||
// Themes are written to <dataDir>/themes/<name>; we also defense-in-depth
|
||||
// check the resolved path stays inside that dir.
|
||||
const THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
||||
|
||||
function assertSafeAssetKey(key) {
|
||||
if (typeof key !== 'string' || key.length === 0 || key.length > 128) {
|
||||
throw new Error(`asset key must be a non-empty string up to 128 chars`);
|
||||
}
|
||||
if (ASSET_PATH_TRAVERSAL_RE.test(key) || !ASSET_KEY_RE.test(key)) {
|
||||
throw new Error(`asset key contains forbidden characters or path segments`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeThemeName(name) {
|
||||
if (typeof name !== 'string' || name.length === 0 || name.length > 128) {
|
||||
throw new Error(`theme name must be a non-empty string up to 128 chars`);
|
||||
}
|
||||
if (!THEME_NAME_RE.test(name)) {
|
||||
throw new Error(`theme name must match ${THEME_NAME_RE} (alphanum / dot / dash / underscore, ending in .json)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject Caddyfile content that smuggles in arbitrary `import` directives.
|
||||
// caddy-apply expects the single top-level Caddyfile; any `import` to an
|
||||
// absolute path means "load another file from disk at Caddy reload time" —
|
||||
// that's a classic injection vector (an attacker can craft a snapshot whose
|
||||
// `import /etc/caddy/external.caddy` reads any file Caddy can read).
|
||||
// We allow the relative-style `import <snippet>` form ONLY if the snippet
|
||||
// name matches a small allowlist of well-known Caddy snippet names (none
|
||||
// today; add explicit names if a future snippet module is needed).
|
||||
const FORBIDDEN_IMPORT_RE = /^\s*import\s+(["']|\/|\.\.|~\/|%[A-F0-9]{2})/im;
|
||||
|
||||
function validateCaddyfileContent(content) {
|
||||
if (typeof content !== 'string') {
|
||||
return { ok: false, error: 'Caddyfile content must be a string' };
|
||||
}
|
||||
if (content.length === 0) {
|
||||
return { ok: false, error: 'Caddyfile content is empty' };
|
||||
}
|
||||
if (Buffer.byteLength(content, 'utf8') > MAX_CADDYFILE_BYTES) {
|
||||
return { ok: false, error: `Caddyfile content exceeds ${MAX_CADDYFILE_BYTES} bytes` };
|
||||
}
|
||||
if (FORBIDDEN_IMPORT_RE.test(content)) {
|
||||
// Allow the canonical single-quoted snippet import form ONLY if the
|
||||
// snippet name is on the explicit allowlist (currently empty). This
|
||||
// catches absolute paths, ../, ~/, and URL-encoded payloads while
|
||||
// leaving room for future snippet additions without touching this gate.
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Caddyfile contains forbidden `import` directive (absolute path, encoded, or non-allowlisted snippet)'
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||
const router = express.Router();
|
||||
@@ -44,6 +119,15 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
||||
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
||||
let lastRestoreStatus = { timestamp: null, status: null };
|
||||
|
||||
// DC-079: Staging dir for the candidate Caddyfile. The disaster-recovery
|
||||
// restore endpoint stages here instead of writing directly to the live
|
||||
// Caddyfile path. The operator must run `caddy-apply` (or its equivalent)
|
||||
// to validate + reload + git-commit the staged file. This keeps the live
|
||||
// Caddyfile under the same atomic-commit guard as every other edit.
|
||||
function getStagedCaddyfileDir(dataDir) {
|
||||
return path.join(dataDir, 'disaster-staged');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/disaster/backup
|
||||
* Creates a complete system snapshot as a downloadable JSON file.
|
||||
@@ -175,13 +259,64 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
||||
}
|
||||
}
|
||||
|
||||
// Restore Caddyfile
|
||||
if (snapshot.caddyfile) {
|
||||
// DC-079: Stage the Caddyfile to a staging path inside dataDir
|
||||
// instead of writing directly to caddyfilePath (which is the LIVE
|
||||
// /etc/caddy/Caddyfile bind-mounted into the container as /caddyfile).
|
||||
//
|
||||
// Threat model (defense-in-depth, mirrors DC-070 / DC-074 / DC-076):
|
||||
// the endpoint is TOTP-gated, but a compromised operator / phished
|
||||
// session / pivot path could POST a snapshot with `caddyfile: <evil>`
|
||||
// and the pre-fix code would call `fsp.writeFile(caddyfilePath, ...)`
|
||||
// which writes the attacker-controlled string straight to the live
|
||||
// Caddyfile. Caddy then reads that file on the next reload (which can
|
||||
// be triggered by ACME renewals, health probes, or any admin API
|
||||
// touch), executing whatever directives the attacker embedded:
|
||||
// - `admin off` + arbitrary config write
|
||||
// - `import /etc/caddy/<anything-caddy-can-read>` for content theft
|
||||
// - `reverse_proxy` to attacker-controlled upstreams
|
||||
// - `acme_ca` override to attacker CA
|
||||
// - `log` directives to attacker-writable paths
|
||||
//
|
||||
// The Caddyfile is managed by the `caddy-apply` wrapper (validates +
|
||||
// reloads + git-commits atomically — see CLAUDE.md hard rule). This
|
||||
// endpoint previously bypassed that wrapper. The fix stages the
|
||||
// candidate file under dataDir/disaster-staged/Caddyfile.candidate and
|
||||
// returns the path so the operator can apply it via the normal flow.
|
||||
const caddyfileStaged = [];
|
||||
// DC-079: handle three cases for the caddyfile field:
|
||||
// - absent/null/undefined: back-compat — no Caddyfile in snapshot
|
||||
// - empty string "": explicit empty payload is suspicious — reject
|
||||
// - non-string (object/array/number): type confusion attempt — reject
|
||||
// - valid string: stage to dataDir/disaster-staged/Caddyfile.candidate
|
||||
if (snapshot.caddyfile !== undefined && snapshot.caddyfile !== null) {
|
||||
const validation = validateCaddyfileContent(snapshot.caddyfile);
|
||||
if (!validation.ok) {
|
||||
return errorResponse(res, 400, `Invalid Caddyfile in snapshot: ${validation.error}`, {
|
||||
code: ErrorCodes.BACKUP.INVALID_CONFIG,
|
||||
});
|
||||
}
|
||||
|
||||
const stagedDir = getStagedCaddyfileDir(dataDir);
|
||||
try {
|
||||
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
|
||||
restored.push('Caddyfile');
|
||||
await fsp.mkdir(stagedDir, { recursive: true });
|
||||
const stagedPath = path.join(stagedDir, 'Caddyfile.candidate');
|
||||
// Atomic write: write to .candidate.tmp then rename. The live
|
||||
// Caddyfile is NEVER touched from this endpoint.
|
||||
const tmpPath = stagedPath + '.tmp';
|
||||
await fsp.writeFile(tmpPath, snapshot.caddyfile, { mode: 0o644 });
|
||||
await fsp.rename(tmpPath, stagedPath);
|
||||
caddyfileStaged.push({
|
||||
file: 'Caddyfile',
|
||||
stagedPath,
|
||||
action: 'awaiting caddy-apply',
|
||||
livePath: caddyfilePath,
|
||||
});
|
||||
if (log) log.info('disaster-recovery', 'Caddyfile staged (not applied)', {
|
||||
stagedPath,
|
||||
size: Buffer.byteLength(snapshot.caddyfile, 'utf8'),
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push({ file: 'Caddyfile', error: err.message });
|
||||
errors.push({ file: 'Caddyfile (staging)', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +324,20 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
||||
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
||||
try {
|
||||
// DC-079: assets directory is the first attack surface that
|
||||
// bypasses the Caddyfile-staging gate. `name` is a user-supplied
|
||||
// JSON key; without validation, `path.join(assetsDir, name)` lets
|
||||
// an attacker escape to /etc/caddy via path traversal.
|
||||
assertSafeAssetKey(name);
|
||||
const resolved = path.resolve(assetsDir, name);
|
||||
// Defense-in-depth: even after charset checks, the resolved path
|
||||
// MUST stay inside assetsDir. If it doesn't, refuse the write.
|
||||
if (!resolved.startsWith(path.resolve(assetsDir) + path.sep) &&
|
||||
resolved !== path.resolve(assetsDir)) {
|
||||
throw new Error(`asset path resolves outside assets directory`);
|
||||
}
|
||||
await fsp.mkdir(assetsDir, { recursive: true });
|
||||
await fsp.writeFile(path.join(assetsDir, name), Buffer.from(base64, 'base64'));
|
||||
await fsp.writeFile(resolved, Buffer.from(base64, 'base64'));
|
||||
restored.push(`assets/${name}`);
|
||||
} catch (err) {
|
||||
errors.push({ file: `assets/${name}`, error: err.message });
|
||||
@@ -203,8 +350,21 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
||||
try {
|
||||
await fsp.mkdir(themesDir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(snapshot.themes)) {
|
||||
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
|
||||
restored.push(`themes/${name}`);
|
||||
// DC-079: same path-traversal vector as assets — keys are
|
||||
// user-controlled JSON. Validate the name AND confirm the
|
||||
// resolved path stays inside themesDir.
|
||||
try {
|
||||
assertSafeThemeName(name);
|
||||
const resolved = path.resolve(themesDir, name);
|
||||
if (!resolved.startsWith(path.resolve(themesDir) + path.sep) &&
|
||||
resolved !== path.resolve(themesDir)) {
|
||||
throw new Error(`theme path resolves outside themes directory`);
|
||||
}
|
||||
await fsp.writeFile(resolved, JSON.stringify(content, null, 2));
|
||||
restored.push(`themes/${name}`);
|
||||
} catch (err) {
|
||||
errors.push({ file: `themes/${name}`, error: err.message });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push({ file: 'themes', error: err.message });
|
||||
@@ -215,19 +375,33 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
||||
timestamp: new Date().toISOString(),
|
||||
status: errors.length === 0 ? 'success' : 'partial',
|
||||
restored: restored.length,
|
||||
staged: caddyfileStaged.length,
|
||||
errors: errors.length,
|
||||
};
|
||||
|
||||
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
||||
|
||||
ok(res, {
|
||||
// DC-079: Surface the staged-Caddyfile warning in the response body so
|
||||
// the UI / operator can see that the Caddyfile is NOT yet live. The
|
||||
// restore endpoint stages under dataDir/disaster-staged/Caddyfile.candidate
|
||||
// and the operator must run `caddy-apply` (or its equivalent) to
|
||||
// validate + reload + git-commit the staged file. The live Caddyfile
|
||||
// is owned by the caddy-apply wrapper per CLAUDE.md hard rule.
|
||||
const responseBody = {
|
||||
status: errors.length === 0 ? 'success' : 'partial',
|
||||
restored,
|
||||
errors,
|
||||
message: errors.length === 0
|
||||
? `Successfully restored ${restored.length} files. Restart DashCaddy to apply.`
|
||||
? `Successfully restored ${restored.length} files${caddyfileStaged.length > 0 ? ` (Caddyfile staged — ${caddyfileStaged[0].stagedPath}; run caddy-apply to apply)` : ''}. Restart DashCaddy to apply.`
|
||||
: `Restored ${restored.length} files with ${errors.length} errors. Check error details.`,
|
||||
});
|
||||
};
|
||||
|
||||
if (caddyfileStaged.length > 0) {
|
||||
responseBody.caddyfileStaged = caddyfileStaged;
|
||||
responseBody.warning = '[DC-079] Caddyfile is STAGED, not applied. Live /etc/caddy/Caddyfile was NOT modified by this restore. Run `caddy-apply <reason>` (or equivalent) to validate + reload + git-commit the staged candidate.';
|
||||
}
|
||||
|
||||
ok(res, responseBody);
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
const { ValidationError } = require('../src/utilities/errors');
|
||||
|
||||
/**
|
||||
* Disk space management routes
|
||||
@@ -10,6 +11,76 @@ const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
* POST /disk/config — update disk budget settings
|
||||
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
||||
*/
|
||||
|
||||
// DC-059: monotonic-ordering invariant for the three threshold percentages.
|
||||
// DiskSpaceMonitor._getBudgetStatus() walks them in order
|
||||
// (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and
|
||||
// returns at the FIRST threshold the usage crosses. If a caller writes
|
||||
// them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60),
|
||||
// the higher-priority branches become unreachable and the monitor silently
|
||||
// misclassifies budget state. Validate against the *effective* config
|
||||
// (current value + incoming update for each field) so partial updates can
|
||||
// be applied one field at a time without violating the invariant.
|
||||
//
|
||||
// Clamp values to the same ranges the previous inline Math.min/Math.max
|
||||
// chains enforced (warning 50..99, critical 60..99, aggressive 70..99)
|
||||
// so we don't loosen the original bounds while adding the new check.
|
||||
const THRESHOLD_BOUNDS = Object.freeze({
|
||||
warning: { min: 50, max: 99 },
|
||||
critical: { min: 60, max: 99 },
|
||||
aggressive: { min: 70, max: 99 },
|
||||
});
|
||||
|
||||
function clampThreshold(name, value) {
|
||||
const { min, max } = THRESHOLD_BOUNDS[name];
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a candidate update to a baseline config, then verify the three
|
||||
* threshold percentages still satisfy
|
||||
* warningThresholdPct < criticalThresholdPct < cleanupAggressivePct.
|
||||
* The POST /config endpoint accepts partial updates (single field at a
|
||||
* time), so we merge into the live diskSpaceMonitor config first, then test
|
||||
* the merged value. Returns the merged candidate on success; throws
|
||||
* ValidationError if the ordering invariant would be violated.
|
||||
*
|
||||
* @param {Object} baseline - current effective config from diskSpaceMonitor
|
||||
* @param {Object} candidate - the partial update being applied this request
|
||||
* @returns {Object} merged candidate with thresholds clamped to bounds
|
||||
*/
|
||||
function mergeAndCheckOrdering(baseline, candidate) {
|
||||
const next = { ...baseline };
|
||||
if (typeof candidate.warningThresholdPct === 'number') {
|
||||
next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct);
|
||||
}
|
||||
if (typeof candidate.criticalThresholdPct === 'number') {
|
||||
next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct);
|
||||
}
|
||||
if (typeof candidate.cleanupAggressivePct === 'number') {
|
||||
next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct);
|
||||
}
|
||||
if (!(next.warningThresholdPct < next.criticalThresholdPct)) {
|
||||
throw new ValidationError(
|
||||
`warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`,
|
||||
'warningThresholdPct'
|
||||
);
|
||||
}
|
||||
if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) {
|
||||
throw new ValidationError(
|
||||
`criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`,
|
||||
'criticalThresholdPct'
|
||||
);
|
||||
}
|
||||
// Return only the fields the caller asked to change (preserves partial-
|
||||
// update semantics; diskSpaceMonitor.configure does its own merge).
|
||||
const out = {};
|
||||
if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct;
|
||||
if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct;
|
||||
if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct;
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -36,9 +107,16 @@ module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||
|
||||
const updates = {};
|
||||
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
||||
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
|
||||
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
|
||||
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
|
||||
// DC-059: threshold percentages must satisfy a strict monotonic order
|
||||
// (warning < critical < aggressive) so _getBudgetStatus() reaches the
|
||||
// correct branch. mergeAndCheckOrdering() validates against the live
|
||||
// baseline, so partial updates that violate the invariant are rejected
|
||||
// BEFORE we mutate diskSpaceMonitor.diskConfig.
|
||||
const thresholdUpdates = mergeAndCheckOrdering(
|
||||
diskSpaceMonitor.getConfig(),
|
||||
{ warningThresholdPct, criticalThresholdPct, cleanupAggressivePct }
|
||||
);
|
||||
Object.assign(updates, thresholdUpdates);
|
||||
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -4,6 +4,9 @@ const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants');
|
||||
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
||||
const { validateURL } = require('../src/security/input-validator');
|
||||
const { ok, successMessage } = require('../src/utils/responses');
|
||||
// DC-074: SSRF defense — reject upstream hosts that resolve to
|
||||
// private/reserved ranges before they reach the Caddyfile.
|
||||
const { validateUpstream } = require('../src/utilities/fleet-validation');
|
||||
|
||||
/**
|
||||
* Sites route factory
|
||||
@@ -166,8 +169,25 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
||||
if (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
|
||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||
|
||||
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
|
||||
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
|
||||
// DC-074: SSRF defense — reject upstreams that resolve to private/
|
||||
// reserved ranges BEFORE we write them into the Caddyfile. Without
|
||||
// this, an authenticated dashboard operator can call POST /api/v1/site
|
||||
// with `upstream: '10.0.0.1:80'` and end up with a Caddy site block
|
||||
// that proxies public traffic to an internal host. Caddy runs on
|
||||
// DNS2 (same network as the targets), so the SSRF lands.
|
||||
//
|
||||
// The existing upstreamRegex /^[a-z0-9.-]+:\d{1,5}$/i only checks
|
||||
// charset — it happily accepts 192.168.1.1:80 and 169.254.169.254:80
|
||||
// (the AWS metadata IP). validateUpstream() also does a DNS lookup
|
||||
// for hostnames so a malicious operator can't sneak a public-looking
|
||||
// domain past the gate and have it resolve to a private IP later.
|
||||
const upstreamCheck = await validateUpstream(upstream);
|
||||
if (!upstreamCheck.ok) {
|
||||
// Don't echo attacker-supplied hostnames in the audit log; keep the
|
||||
// canonical code + message but never write the raw value.
|
||||
log?.warn?.('site', 'POST /site rejected by SSRF gate', { code: upstreamCheck.code });
|
||||
throw new ValidationError(`[DC-074] ${upstreamCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
|
||||
}
|
||||
|
||||
const content = await caddy.read();
|
||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
@@ -199,12 +219,40 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a
|
||||
throw new ValidationError('[DC-301] Invalid subdomain format');
|
||||
}
|
||||
|
||||
// DC-074: SSRF defense — validate the URL syntax via validateURL() (catches
|
||||
// non-http(s) schemes, malformed URLs) AND validateUpstream() (catches
|
||||
// every private/reserved range including CGNAT, multicast, TEST-NET
|
||||
// ranges that validateURL's isPrivateIP() regex misses).
|
||||
//
|
||||
// We intentionally do NOT pass `blockPrivate: true` to validateURL()
|
||||
// here — that's handled by validateUpstream() below, which honors the
|
||||
// SITES_ALLOW_PRIVATE_UPSTREAMS opt-in. validateURL's blockPrivate path
|
||||
// is a hard reject with no escape hatch, which would force operators
|
||||
// who intentionally proxy to a private target to remove validation
|
||||
// entirely.
|
||||
try {
|
||||
validateURL(externalUrl);
|
||||
} catch (validationErr) {
|
||||
throw new ValidationError(validationErr.message);
|
||||
}
|
||||
|
||||
// DC-074: validateUpstream() does the same rigorous private-IP check
|
||||
// fleet-validation shipped for DC-068, with full CGNAT / multicast /
|
||||
// broadcast / 0.0.0.0 / TEST-NET / benchmark range coverage and a DNS
|
||||
// resolution step for hostnames (rebinding defense).
|
||||
let parsedExternalUrl;
|
||||
try {
|
||||
parsedExternalUrl = new URL(externalUrl);
|
||||
} catch (_) {
|
||||
// validateURL() above already gates URL syntax — unreachable.
|
||||
throw new ValidationError('Invalid external URL');
|
||||
}
|
||||
const externalCheck = await validateUpstream(`${parsedExternalUrl.hostname}:${parsedExternalUrl.port || (parsedExternalUrl.protocol === 'https:' ? '443' : '80')}`);
|
||||
if (!externalCheck.ok) {
|
||||
log?.warn?.('site', 'POST /site/external rejected by SSRF gate', { code: externalCheck.code });
|
||||
throw new ValidationError(`[DC-074] ${externalCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`);
|
||||
}
|
||||
|
||||
const domain = buildDomain(subdomain);
|
||||
let dnsWarning = null;
|
||||
|
||||
|
||||
@@ -41,12 +41,124 @@
|
||||
*
|
||||
* DELETE /api/v1/tailscale/admin/devices/:id
|
||||
* Revokes a device from the tailnet.
|
||||
*
|
||||
* # DC-080 input validation
|
||||
*
|
||||
* Three coupled gaps in the route layer pre-fix:
|
||||
*
|
||||
* (a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but had
|
||||
* no length cap — body-parser limit was the only ceiling. A 1 MB
|
||||
* string starting with `tskey-api-` would be `.trim()`-ed, sent to
|
||||
* Tailscale's /devices endpoint, and waste server-side CPU on a
|
||||
* request that will always 401.
|
||||
* (b) POST /settings/test accepted `apiToken` from the body with NO
|
||||
* validation at all. The PUT route's prefix check is bypassed on
|
||||
* the test path — an operator could submit any string and have the
|
||||
* container ping Tailscale's API with it (low impact, but inconsistent
|
||||
* with PUT and surfaces fingerprinting via the 401 timing).
|
||||
* (c) POST /admin/keys validated `tags` as Array but NOT per-element
|
||||
* type — `tags: ['tag:guest', null, 123, {injection: true}]` would be
|
||||
* forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
|
||||
* and would 400 the request, but the bad shape reached the wire.
|
||||
* Similarly `description` had no length cap (Tailscale caps at 120
|
||||
* chars per their docs).
|
||||
*
|
||||
* All three are gated by TOTP — this is a logged-in-operator / phished-
|
||||
* session threat surface, not anonymous-unauth. The fix is defense-in-
|
||||
* depth: a bug in the auth path (TOTP bypass, session theft, future
|
||||
* route handler trust-boundary drift) should not turn these endpoints
|
||||
* into a "submit anything and forward to Tailscale" relay.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { ok, errorResponse } = require('../src/utils/responses');
|
||||
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
||||
|
||||
// DC-080: shared validation helpers for the Tailscale admin surface.
|
||||
// Tailscale API tokens follow the form `tskey-<kind>-<opaque>` where
|
||||
// `<kind>` is one of a small set of values (`api`, `auth`, `partner`,
|
||||
// `cli`). Real tokens observed in the wild are 40..80 chars; we cap at
|
||||
// 256 to leave headroom for future Tailscale key formats without giving
|
||||
// an unbounded buffer to validate+forward.
|
||||
const TAILSCALE_TOKEN_PREFIX = 'tskey-api-';
|
||||
const TAILSCALE_TOKEN_MAX_LEN = 256;
|
||||
const TAG_KEY_MAX_LEN = 64;
|
||||
const TAGS_MAX_LEN = 32;
|
||||
const DESCRIPTION_MAX_LEN = 120;
|
||||
|
||||
// Tailscale tags are lowercased identifiers with optional colons
|
||||
// (e.g. `tag:server`, `tag:guest-plex`). Reject whitespace, CR/LF,
|
||||
// control chars, JSON metacharacters, and any character that could
|
||||
// enable header-injection through the Tailscale coord client.
|
||||
//
|
||||
// DC-080 round-2 polish: Tailscale's tag spec requires `tag:` followed by
|
||||
// ≥1 identifier char — bare `tag:` (empty name) is rejected by their API.
|
||||
// We split the pattern in two so the error message names which form failed
|
||||
// instead of dumping a generic regex.
|
||||
const TAG_KEY_RE = /^tag:[a-z0-9][a-z0-9_-]{0,62}$/;
|
||||
|
||||
function _validateApiToken(token, fieldName = 'apiToken') {
|
||||
if (typeof token !== 'string' || !token) {
|
||||
return `${fieldName} is required and must be a string`;
|
||||
}
|
||||
if (!token.startsWith(TAILSCALE_TOKEN_PREFIX)) {
|
||||
return `${fieldName} must start with ${TAILSCALE_TOKEN_PREFIX}`;
|
||||
}
|
||||
if (token.length > TAILSCALE_TOKEN_MAX_LEN) {
|
||||
return `${fieldName} exceeds maximum length of ${TAILSCALE_TOKEN_MAX_LEN} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _validateTags(tags) {
|
||||
if (tags === undefined || tags === null) return null;
|
||||
if (!Array.isArray(tags)) {
|
||||
return 'tags must be an array of strings';
|
||||
}
|
||||
if (tags.length > TAGS_MAX_LEN) {
|
||||
return `tags exceeds maximum length of ${TAGS_MAX_LEN} entries`;
|
||||
}
|
||||
for (let i = 0; i < tags.length; i += 1) {
|
||||
const t = tags[i];
|
||||
if (typeof t !== 'string' || !t) {
|
||||
return `tags[${i}] must be a non-empty string`;
|
||||
}
|
||||
if (t.length > TAG_KEY_MAX_LEN) {
|
||||
return `tags[${i}] exceeds maximum length of ${TAG_KEY_MAX_LEN} characters`;
|
||||
}
|
||||
if (!TAG_KEY_RE.test(t)) {
|
||||
return `tags[${i}] must match ${TAG_KEY_RE} (lowercase alnum + :_-)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _validateDescription(description) {
|
||||
if (description === undefined || description === null) return null;
|
||||
if (typeof description !== 'string') {
|
||||
return 'description must be a string';
|
||||
}
|
||||
if (description.length > DESCRIPTION_MAX_LEN) {
|
||||
return `description exceeds maximum length of ${DESCRIPTION_MAX_LEN} characters`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Exported for direct unit testing in __tests__/routes/tailscale-admin.test.js
|
||||
// (the validator functions are otherwise unreachable from outside the factory
|
||||
// closure; direct tests assert edge cases without supertest overhead).
|
||||
const _validators = {
|
||||
validateApiToken: _validateApiToken,
|
||||
validateTags: _validateTags,
|
||||
validateDescription: _validateDescription,
|
||||
TAILSCALE_TOKEN_PREFIX,
|
||||
TAILSCALE_TOKEN_MAX_LEN,
|
||||
TAG_KEY_MAX_LEN,
|
||||
TAGS_MAX_LEN,
|
||||
DESCRIPTION_MAX_LEN,
|
||||
TAG_KEY_RE,
|
||||
};
|
||||
|
||||
module.exports = function({
|
||||
tailscaleCoord,
|
||||
asyncHandler,
|
||||
@@ -75,9 +187,12 @@ module.exports = function({
|
||||
|
||||
router.put('/settings', asyncHandler(async (req, res) => {
|
||||
const token = req.body && req.body.apiToken;
|
||||
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
|
||||
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
|
||||
}
|
||||
// DC-080: validate prefix + length cap. The pre-fix code only checked
|
||||
// the prefix — a 1 MB string starting with `tskey-api-` would have been
|
||||
// sent to Tailscale's /devices endpoint and wasted server-side CPU
|
||||
// before the inevitable 401.
|
||||
const tokenErr = _validateApiToken(token);
|
||||
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||
|
||||
// Validate before storing
|
||||
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
||||
@@ -130,6 +245,17 @@ module.exports = function({
|
||||
|
||||
router.post('/settings/test', asyncHandler(async (req, res) => {
|
||||
const token = (req.body && req.body.apiToken) || null;
|
||||
// DC-080: validate any caller-provided token before it reaches the
|
||||
// Tailscale API. Pre-fix the test endpoint accepted any string — the
|
||||
// PUT route's prefix check did NOT extend to this path. An operator
|
||||
// could submit arbitrary junk and the container would still call
|
||||
// /devices on the Tailscale API with it (DoS-reflection + fingerprint
|
||||
// timing for a future attacker probing whether this API token format
|
||||
// is accepted at all).
|
||||
if (token !== null && token !== undefined) {
|
||||
const tokenErr = _validateApiToken(token);
|
||||
if (tokenErr) return errorResponse(res, 400, tokenErr);
|
||||
}
|
||||
const client = await tailscaleCoord.getClient();
|
||||
if (token) {
|
||||
// Caller provided a fresh token to test — don't save it
|
||||
@@ -214,10 +340,16 @@ module.exports = function({
|
||||
return errorResponse(res, 503, 'Tailscale API token not configured');
|
||||
}
|
||||
const opts = req.body || {};
|
||||
// Reject obviously-bad input early
|
||||
if (opts.tags && !Array.isArray(opts.tags)) {
|
||||
return errorResponse(res, 400, 'tags must be an array of strings');
|
||||
}
|
||||
// Reject obviously-bad input early.
|
||||
// DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`.
|
||||
// A `tags: ['tag:guest', null, 123, {injection: true}]` payload would
|
||||
// be forwarded to Tailscale verbatim — Tailscale's API is JSON-strict
|
||||
// and would 400 the request, but the bad shape reached the wire and
|
||||
// would silently pass through the dashboard's JSON.stringify() flow.
|
||||
const tagsErr = _validateTags(opts.tags);
|
||||
if (tagsErr) return errorResponse(res, 400, tagsErr);
|
||||
const descErr = _validateDescription(opts.description);
|
||||
if (descErr) return errorResponse(res, 400, descErr);
|
||||
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
||||
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
||||
}
|
||||
@@ -254,4 +386,10 @@ module.exports = function({
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
};
|
||||
|
||||
// DC-080: validators exported for direct unit testing in
|
||||
// __tests__/routes/tailscale-admin.test.js — the route factory closes
|
||||
// over the same functions, so the validators are exercised end-to-end via
|
||||
// supertest AND in isolation here.
|
||||
module.exports._validators = _validators;
|
||||
+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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -31,6 +31,17 @@ module.exports = {
|
||||
CADDY_ADMIN_URL,
|
||||
SERVICES_FILE,
|
||||
SERVICES_DIR,
|
||||
// Re-export the resolved data directory so other modules (notably
|
||||
// src/utilities/nesting-guard.js) can locate `/app/data` without having to
|
||||
// also require('../../platform-paths') — keeps a single source of truth for
|
||||
// the data dir on the src/config/paths surface. Without this, `dataDir`
|
||||
// resolves to `undefined`, and `path.join(undefined, 'data')` throws
|
||||
// `TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type
|
||||
// string. Received undefined` at startup (DC-077 fingerprint). Fall back to
|
||||
// platformPaths.dataDir if SERVICES_DIR is somehow not a string (defensive —
|
||||
// SERVICES_DIR is computed from a path.dirname() of a string so it always
|
||||
// is, but the cost of guarding is one branch).
|
||||
dataDir: typeof SERVICES_DIR === 'string' && SERVICES_DIR ? SERVICES_DIR : platformPaths.dataDir,
|
||||
CONFIG_FILE,
|
||||
DNS_CREDENTIALS_FILE,
|
||||
TAILSCALE_CONFIG_FILE,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,30 @@ const UPDATE_CONFIG_FILE = process.env.UPDATE_CONFIG_FILE || path.join(platformP
|
||||
const UPDATE_HISTORY_FILE = process.env.UPDATE_HISTORY_FILE || path.join(platformPaths.dataDir, 'update-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.UPDATE_CHECK_INTERVAL || '3600000', 10); // 1 hour
|
||||
|
||||
// DC-078: registry probe reliability knobs. The container's /etc/resolv.conf points
|
||||
// at Technitium (100.121.150.22) which sometimes returns a mix of A and AAAA
|
||||
// records even when the host's IPv6 path to public registries (Docker Hub,
|
||||
// ghcr.io) is broken or slow. Without `family: 4` Node defaults to dual-stack,
|
||||
// every `https.request` to a registry races dual-stack DNS and stalls 30+ seconds
|
||||
// per ENETUNREACH on the unreachable family. Without an explicit request timeout
|
||||
// the entire `checkForUpdates()` loop (5+ containers) blocks for minutes per
|
||||
// tick — visible in error.log as AggregateError [ETIMEDOUT] with a stack like
|
||||
// `at internalConnectMultiple (node:net:1114:18)`.
|
||||
//
|
||||
// TUNABLES — keep conservative; the digest check is a background poll, not
|
||||
// user-facing. Worst-case latency per query:
|
||||
// 1st attempt: REGISTRY_REQUEST_TIMEOUT_MS (10s)
|
||||
// 1st retry : REGISTRY_RETRY_BACKOFF_MS + REGISTRY_REQUEST_TIMEOUT_MS (10.5s)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// per-container ceiling: 20.5s (REGISTRY_MAX_RETRIES=1)
|
||||
const REGISTRY_REQUEST_TIMEOUT_MS = 10000; // hard per-request socket timeout
|
||||
const REGISTRY_MAX_RETRIES = 1; // extra attempts after first failure
|
||||
const REGISTRY_RETRY_BACKOFF_MS = 500; // delay before retry (transient blips)
|
||||
const REGISTRY_TRANSIENT_ERROR_CODES = new Set([
|
||||
'ETIMEDOUT', 'ENOTFOUND', 'ENETUNREACH', 'ECONNRESET', 'EAI_AGAIN',
|
||||
'EPIPE', 'ECONNREFUSED', 'EHOSTUNREACH',
|
||||
]);
|
||||
|
||||
class UpdateManager extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
@@ -181,87 +205,208 @@ class UpdateManager extends EventEmitter {
|
||||
* Get image digest from GitHub Container Registry (ghcr.io)
|
||||
* Public images are tokenless via the registry-1.docker.io-style bearer flow,
|
||||
* but using ghcr.io's own auth endpoint.
|
||||
*
|
||||
* DC-078: hardened — `family: 4` to avoid the dual-stack DNS race when the
|
||||
* host's IPv6 path is unreachable (was producing AggregateError [ETIMEDOUT] in
|
||||
* error.log every check cycle). Hard request timeout caps each attempt.
|
||||
*/
|
||||
async getGhcrDigest(repository, tag) {
|
||||
// ghcr.io uses the same OCI distribution spec as Docker Hub
|
||||
const imageRepo = repository.replace(/^ghcr\.io\//, '');
|
||||
const res = await this.fetchWithReliability({
|
||||
hostname: 'ghcr.io',
|
||||
path: `/v2/${imageRepo}/manifests/${tag}`,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
||||
},
|
||||
});
|
||||
return res.headers['docker-content-digest'] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image digest from Docker Hub
|
||||
*
|
||||
* DC-078: hardened — see getGhcrDigest comment. Resolves a 401 → token via
|
||||
* `fetchAuthToken`, which itself is wrapped in the same retry + IPv4-only +
|
||||
* timeout policy via `fetchWithReliability`.
|
||||
*/
|
||||
async getDockerHubDigest(repository, tag) {
|
||||
// Normalize repository name
|
||||
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
||||
const firstAttempt = await this.fetchWithReliability({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: `/v2/${repo}/manifests/${tag}`,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
},
|
||||
});
|
||||
if (firstAttempt.statusCode !== 401) {
|
||||
if (firstAttempt.statusCode < 200 || firstAttempt.statusCode >= 300) {
|
||||
throw new Error(`Docker Hub registry returned HTTP ${firstAttempt.statusCode}`);
|
||||
}
|
||||
return firstAttempt.headers['docker-content-digest'] || null;
|
||||
}
|
||||
// 401 → acquire a Bearer token via the WWW-Authenticate realm, then retry once.
|
||||
const authHeader = firstAttempt.headers['www-authenticate'];
|
||||
const authUrl = this.parseAuthHeader(authHeader);
|
||||
if (!authUrl) {
|
||||
throw new Error('Authentication required but no auth URL found');
|
||||
}
|
||||
const token = await this.fetchAuthToken(authUrl);
|
||||
const authed = await this.fetchWithReliability({
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: `/v2/${repo}/manifests/${tag}`,
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (authed.statusCode < 200 || authed.statusCode >= 300) {
|
||||
throw new Error(`Docker Hub registry returned HTTP ${authed.statusCode} after auth`);
|
||||
}
|
||||
return authed.headers['docker-content-digest'] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single hardened HTTPS probe — DC-078.
|
||||
*
|
||||
* Reliability properties:
|
||||
* 1. `family: 4` — IPv4-only DNS lookup. Avoids dual-stack races where a
|
||||
* single unreachable IPv6 destination consumes the default 30-second
|
||||
* connect timeout before the IPv4 fallback succeeds (manifested in
|
||||
* error.log as AggregateError [ETIMEDOUT] with `at internalConnectMultiple`).
|
||||
* 2. Hard per-request timeout (REGISTRY_REQUEST_TIMEOUT_MS) — caps total
|
||||
* latency for any single probe attempt.
|
||||
* 3. Retry on transient network errors (REGISTRY_TRANSIENT_ERROR_CODES)
|
||||
* with REGISTRY_RETRY_BACKOFF_MS delay between attempts. Does NOT
|
||||
* retry on HTTP 4xx/5xx — those are real responses we should surface.
|
||||
*
|
||||
* Returns {statusCode, headers, body} so callers can read whichever response
|
||||
* header or body bytes they need. For digest probes the body is drained and
|
||||
* discarded; for auth-token fetches the JSON body is parsed.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.hostname
|
||||
* @param {string} opts.path
|
||||
* @param {object} [opts.headers]
|
||||
* @param {number} [opts.maxBodyBytes=65536] — protect against runaway bodies
|
||||
*/
|
||||
async fetchWithReliability(opts) {
|
||||
const maxBodyBytes = opts.maxBodyBytes || 65536;
|
||||
let attempt = 0;
|
||||
while (attempt <= REGISTRY_MAX_RETRIES) {
|
||||
try {
|
||||
const result = await this._httpsRequestOnce({
|
||||
hostname: opts.hostname,
|
||||
path: opts.path,
|
||||
headers: opts.headers || {},
|
||||
maxBodyBytes,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Drain retryable transient errors; non-transient (HTTP status) errors
|
||||
// and code-less errors are surfaced directly to the caller.
|
||||
if (!REGISTRY_TRANSIENT_ERROR_CODES.has(error && error.code)) {
|
||||
throw error;
|
||||
}
|
||||
if (attempt >= REGISTRY_MAX_RETRIES) {
|
||||
throw error;
|
||||
}
|
||||
attempt += 1;
|
||||
// Brief backoff before retry to let transient blips settle.
|
||||
await new Promise((resolve) => setTimeout(resolve, REGISTRY_RETRY_BACKOFF_MS));
|
||||
}
|
||||
}
|
||||
// Defensive — should not reach here because the loop either throws or returns.
|
||||
throw new Error('fetchWithReliability exhausted retries');
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot HTTPS request helper for fetchWithReliability — DC-078.
|
||||
* Returns {statusCode, headers, body} on 2xx and most non-2xx responses
|
||||
* (the caller decides what to do with non-2xx). Throws on transient
|
||||
* network errors so the retry policy catches them.
|
||||
*/
|
||||
_httpsRequestOnce({ hostname, path: urlPath, headers, maxBodyBytes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'ghcr.io',
|
||||
path: `/v2/${imageRepo}/manifests/${tag}`,
|
||||
hostname,
|
||||
path: urlPath,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.docker.distribution.manifest.list.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json'
|
||||
}
|
||||
family: 4, // DC-078: IPv4-only — see top-of-file comment
|
||||
headers,
|
||||
timeout: REGISTRY_REQUEST_TIMEOUT_MS, // DC-078: hard per-request cap
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
const authHeader = res.headers['www-authenticate'];
|
||||
const authUrl = this.parseAuthHeader(authHeader);
|
||||
if (authUrl) {
|
||||
// ghcr.io auth endpoint accepts scope=repository:owner/name:pull
|
||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('Authentication required but no auth URL found'));
|
||||
let body = '';
|
||||
let size = 0;
|
||||
let aborted = false;
|
||||
res.on('data', (chunk) => {
|
||||
if (aborted) return;
|
||||
size += chunk.length;
|
||||
if (size > maxBodyBytes) {
|
||||
aborted = true;
|
||||
res.destroy();
|
||||
const err = new Error(`response from ${hostname}${urlPath} exceeded ${maxBodyBytes} bytes`);
|
||||
err.code = 'ERR_RESPONSE_TOO_LARGE';
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
// Drain body to avoid socket leak
|
||||
res.resume();
|
||||
reject(new Error(`ghcr.io returned HTTP ${res.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const digest = res.headers['docker-content-digest'];
|
||||
resolve(digest || null);
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (aborted) return;
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
headers: res.headers,
|
||||
body,
|
||||
});
|
||||
});
|
||||
});
|
||||
// Node 22 emits 'timeout' on the request, not the socket, when socket.setTimeout
|
||||
// is hit — make it an explicit error so fetchWithReliability's retry policy catches it.
|
||||
req.on('timeout', () => {
|
||||
req.destroy(new Error('request timeout'));
|
||||
const err = new Error(`registry request to ${hostname}${urlPath} timed out after ${REGISTRY_REQUEST_TIMEOUT_MS}ms`);
|
||||
err.code = 'ETIMEDOUT';
|
||||
reject(err);
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
// Tag errors missing .code so the retry policy recognizes transient ones.
|
||||
if (!err.code && /timeout/i.test(err.message)) err.code = 'ETIMEDOUT';
|
||||
reject(err);
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image digest from Docker Hub
|
||||
* Fetch an auth token from a registry's WWW-Authenticate realm URL — DC-078.
|
||||
* Uses fetchWithReliability for IPv4-only + timeout + retry. Parses the
|
||||
* JSON body and returns the `token` or `access_token` field.
|
||||
*/
|
||||
async getDockerHubDigest(repository, tag) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Normalize repository name
|
||||
const repo = repository.includes('/') ? repository : `library/${repository}`;
|
||||
|
||||
const options = {
|
||||
hostname: 'registry-1.docker.io',
|
||||
path: `/v2/${repo}/manifests/${tag}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
if (res.statusCode === 401) {
|
||||
// Need to authenticate
|
||||
const authHeader = res.headers['www-authenticate'];
|
||||
const authUrl = this.parseAuthHeader(authHeader);
|
||||
|
||||
if (authUrl) {
|
||||
this.authenticateAndGetDigest(authUrl, options).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('Authentication required but no auth URL found'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const digest = res.headers['docker-content-digest'];
|
||||
resolve(digest || null);
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
async fetchAuthToken(authUrl) {
|
||||
const url = new URL(authUrl);
|
||||
const result = await this.fetchWithReliability({
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
maxBodyBytes: 16384, // auth tokens are <2 KB; cap to a small bound
|
||||
});
|
||||
if (result.statusCode !== 200) {
|
||||
throw new Error(`auth token endpoint ${authUrl} returned HTTP ${result.statusCode}`);
|
||||
}
|
||||
let auth;
|
||||
try {
|
||||
auth = JSON.parse(result.body);
|
||||
} catch (parseErr) {
|
||||
// Surface a clean error — otherwise a malformed token response throws
|
||||
// SyntaxError with the raw body snippet, which is hard to diagnose
|
||||
// against the offending realm URL in a log line.
|
||||
throw new Error(`auth token response from ${authUrl} was not valid JSON: ${parseErr.message}`);
|
||||
}
|
||||
const token = auth.token || auth.access_token;
|
||||
if (!token) throw new Error(`No token in auth response from ${authUrl}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -283,48 +428,6 @@ class UpdateManager extends EventEmitter {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate and get digest
|
||||
*/
|
||||
async authenticateAndGetDigest(authUrl, originalOptions) {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(authUrl, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const auth = JSON.parse(data);
|
||||
const token = auth.token || auth.access_token;
|
||||
|
||||
if (!token) {
|
||||
reject(new Error('No token in auth response'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry original request with token
|
||||
const options = {
|
||||
...originalOptions,
|
||||
headers: {
|
||||
...originalOptions.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
const digest = res.headers['docker-content-digest'];
|
||||
resolve(digest || null);
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tag from image name
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* 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 || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `host:port` upstream string for use in Caddy's `reverse_proxy`.
|
||||
*
|
||||
* DC-074 SSRF hardening: an authenticated dashboard operator can call
|
||||
* POST /api/v1/site with `upstream: '10.0.0.1:80'` and end up with a
|
||||
* Caddyfile entry that proxies public traffic (https://attacker.example.com)
|
||||
* to an INTERNAL host (10.0.0.1:80). Caddy runs on DNS2 — same network
|
||||
* as the targets — so the proxy lands the request on the private host.
|
||||
* The operator doesn't even need DNS-rebinding tricks: a literal IPv4
|
||||
* like 192.168.1.1 is accepted by the existing `[a-z0-9.-]+:\d{1,5}`
|
||||
* upstream regex.
|
||||
*
|
||||
* Reuses `resolveAndCheckAddress()` to:
|
||||
* - reject literal private IPv4 / IPv6
|
||||
* - resolve DNS names and reject any private-IP answer
|
||||
* (rebinding defense — the actual address Caddy connects to is
|
||||
* the resolved IP at registration time; Caddy itself resolves
|
||||
* the name per-request, so a malicious operator could flip the
|
||||
* A record between registration and connection. Acceptable
|
||||
* residual risk — the registration check is the main gate.)
|
||||
* - cap port to 1..65535 (defense vs. `host:99999999` integer
|
||||
* overflow / Caddy parser-bomb)
|
||||
*
|
||||
* Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who
|
||||
* intentionally proxy to private targets (faster than a public DNS
|
||||
* round-trip + central control plane).
|
||||
*
|
||||
* @param {string} upstream - "host:port" string (e.g. "10.0.0.1:80")
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.allowPrivate] - override the env-var default
|
||||
* @returns {Promise<{ok: true, host: string, port: number, resolvedIp?: string, family?: number} | {ok: false, code: string, message: string}>}
|
||||
*/
|
||||
async function validateUpstream(upstream, opts = {}) {
|
||||
if (typeof upstream !== 'string' || upstream.length === 0) {
|
||||
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream is required' };
|
||||
}
|
||||
|
||||
// Split on the LAST colon so IPv6 literals like `[::1]:80` parse
|
||||
// correctly (and a malformed `[::1]` without port is rejected with
|
||||
// a clean code, not a confusing TypeError from Number()).
|
||||
const lastColon = upstream.lastIndexOf(':');
|
||||
if (lastColon < 0) {
|
||||
return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream must be host:port' };
|
||||
}
|
||||
const host = upstream.slice(0, lastColon);
|
||||
const portStr = upstream.slice(lastColon + 1);
|
||||
|
||||
const portNum = Number(portStr);
|
||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
|
||||
return { ok: false, code: 'INVALID_PORT', message: 'upstream port must be an integer 1..65535' };
|
||||
}
|
||||
|
||||
// Allow-list the host charset BEFORE the DNS lookup so attacker
|
||||
// payloads can't make the resolver do work. Matches the fleet
|
||||
// isValidHostnameSyntax check; sites.js's own `[a-z0-9.-]+` regex
|
||||
// is more restrictive (only letters/digits/dots/hyphens) so
|
||||
// we widen here to also accept bracketed IPv6. Anything else gets
|
||||
// rejected pre-DNS.
|
||||
const isBracketedIPv6 = host.startsWith('[') && host.endsWith(']');
|
||||
const hostToCheck = isBracketedIPv6 ? host.slice(1, -1) : host;
|
||||
if (!isValidHostnameSyntax(hostToCheck) && require('net').isIP(hostToCheck) === 0) {
|
||||
return { ok: false, code: 'INVALID_HOST', message: `upstream host "${host}" is not a valid DNS name or IP address` };
|
||||
}
|
||||
|
||||
const allowPrivate = typeof opts.allowPrivate === 'boolean'
|
||||
? opts.allowPrivate
|
||||
: process.env.SITES_ALLOW_PRIVATE_UPSTREAMS === 'true';
|
||||
|
||||
const r = await resolveAndCheckAddress(hostToCheck, { allowPrivate });
|
||||
if (!r.ok) return r; // bubbles up PRIVATE_IPV4 / PRIVATE_IPV6 / INVALID_HOSTNAME / DNS_*
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
host,
|
||||
port: portNum,
|
||||
resolvedIp: r.ip,
|
||||
family: r.family,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateFleetHost,
|
||||
resolveAndCheckAddress,
|
||||
isPrivateOrReservedIPv4,
|
||||
isPrivateOrReservedIPv6,
|
||||
isValidHostnameSyntax,
|
||||
validateUpstream,
|
||||
};
|
||||
@@ -426,8 +426,21 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/cert/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/ca/certs', exact: true, method: 'GET' },
|
||||
// DC-076: /api/v1/ca/cert/<domain> and /api/v1/ca/certs MUST stay gated
|
||||
// by TOTP/session. The /cert/<domain> endpoint returns the private key
|
||||
// (format=key and format=pem both embed `server.key`; format=pfx wraps
|
||||
// the same key in a PKCS#12 envelope). If an operator disables TOTP at
|
||||
// any point in the future (ops command, fresh install with TOTP off
|
||||
// during setup, .disabled-* rename of totp-config.json), an unauthenticated
|
||||
// attacker reaching `https://ca.sami/api/ca/cert/<any-domain>?format=key`
|
||||
// would receive the per-service RSA private key for every service whose
|
||||
// cert Caddy has ever signed — that's a per-service key disclosure, not
|
||||
// just a CA fingerprint leak. The `/api/v1/ca/info`, `/root.crt`, and
|
||||
// `/install-script` paths above stay public (the root CA cert is public
|
||||
// by design — devices need it to trust *.sami TLS); only the per-service
|
||||
// private key and per-service cert list go behind auth. See DC-076 for
|
||||
// the corresponding rate-limit + admin-scope + password-required
|
||||
// hardening in routes/ca.js.
|
||||
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/logo', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
|
||||
|
||||
@@ -14,8 +14,19 @@ const path = require('path');
|
||||
module.exports = function nestingGuard() {
|
||||
try {
|
||||
const paths = require('../config/paths');
|
||||
const dataDir = paths.dataDir;
|
||||
const dataDataPath = path.join(dataDir, 'data');
|
||||
const dataDir = paths && paths.dataDir;
|
||||
// Defensive: if paths.dataDir is undefined (older callers or a future
|
||||
// export-shape drift), fall back to platformPaths.dataDir directly so the
|
||||
// guard can still execute. Pre-fix this branch was swallowed silently by
|
||||
// the outer try/catch, leaving the entire nesting-guard a no-op (DC-077).
|
||||
const effectiveDataDir = typeof dataDir === 'string' && dataDir
|
||||
? dataDir
|
||||
: require('../../platform-paths').dataDir;
|
||||
if (typeof effectiveDataDir !== 'string' || !effectiveDataDir) {
|
||||
console.warn('[nesting-guard] Skipped: dataDir unavailable from src/config/paths and platform-paths');
|
||||
return;
|
||||
}
|
||||
const dataDataPath = path.join(effectiveDataDir, 'data');
|
||||
|
||||
// If data/data exists, it's a recursive duplicate — remove it
|
||||
if (fs.existsSync(dataDataPath)) {
|
||||
|
||||
@@ -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