Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7bb97129 | ||
|
|
98737995a9 | ||
|
|
99ec6ebc53 | ||
|
|
a7260436d1 | ||
|
|
a4e4b24732 | ||
|
|
0086de97da | ||
|
|
18ffd2e519 |
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,427 @@
|
||||
/**
|
||||
* DC-081: log-insights dispose path + keepDays input validation hardening.
|
||||
*
|
||||
* Two coupled bugs surfaced in the 2026-08-19 sweep:
|
||||
*
|
||||
* 1. log-insights.js hardcoded the audit-log.json + security-events.jsonl
|
||||
* paths to `/opt/dashcaddy/dashcaddy-api/data/...`, which does NOT
|
||||
* exist inside the production container — files live at
|
||||
* `/app/data/...` (mounted via the existing data bind). The dispose
|
||||
* endpoint silently no-op'd: `fs.readFile('/opt/.../audit-log.json')`
|
||||
* hit the `.catch` arm → `auditData = []` → wrote an empty file back.
|
||||
*
|
||||
* 2. `parseInt(req.body.keepDays) || 30` accepted negative numbers. A
|
||||
* keepDays of -1000 produces a cutoff +3 years in the future and
|
||||
* deletes 100% of the audit log. Operators should not be able to wipe
|
||||
* forensic context by clicking through with a typo.
|
||||
*
|
||||
* DC-081 fix:
|
||||
* - `_resolvePaths()` returns `{ auditPath, secPath }` from
|
||||
* `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`
|
||||
* — same canonical resolution as the audit-logger module.
|
||||
* - `_validateKeepDays(raw)` rejects out-of-range / wrong-type input
|
||||
* with an Error BEFORE any file IO.
|
||||
* - POST /log-insights/dispose now requires `{ keepDays: integer 1..3650, confirm: true }`.
|
||||
* The pre-confirm preview is read-only.
|
||||
*
|
||||
* Verified live on DNS2 2026-08-19: `/app/data/audit-log.json` (318 KB)
|
||||
* and `/app/data/security-events.jsonl` (15 MB) both exist; the old
|
||||
* `/opt/dashcaddy/dashcaddy-api/data/...` paths are ENOENT in the container.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const logInsightsMod = require('../../routes/log-insights');
|
||||
|
||||
function tmpAuditLogger() {
|
||||
// The route module only uses auditLogger.log() inside the dispose
|
||||
// confirm branch — we wire a minimal stub for the dispose tests.
|
||||
return {
|
||||
query: async () => [],
|
||||
log: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function tmpSecurityEventStore() {
|
||||
return {
|
||||
query: () => ({ events: [], total: 0 }),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRouter(opts = {}) {
|
||||
const mod = logInsightsMod;
|
||||
return mod({
|
||||
asyncHandler: (fn) => async (req, res, next) => {
|
||||
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||
},
|
||||
ok: (res, data) => res.json({ success: true, ...data }),
|
||||
auditLogger: opts.auditLogger || tmpAuditLogger(),
|
||||
securityEventStore: opts.securityEventStore || tmpSecurityEventStore(),
|
||||
});
|
||||
}
|
||||
|
||||
function makeApp(router) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(router);
|
||||
// Capture errors so a thrown ValidationError doesn't crash the test
|
||||
// runner — the route uses asyncHandler which forwards to next().
|
||||
app.use((err, req, res, next) => res.status(err.statusCode || 500).json({ success: false, error: err.message, code: err.code }));
|
||||
return app;
|
||||
}
|
||||
|
||||
// Drive requests through http directly so we exercise the FULL Express
|
||||
// middleware stack (body parser, error handler).
|
||||
function start(app) {
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function stop(server) {
|
||||
return new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
|
||||
function httpJson(server, httpMethod, urlPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = server.address().port;
|
||||
const data = httpMethod === 'GET' ? '' : JSON.stringify({});
|
||||
const req = require('http').request({
|
||||
hostname: '127.0.0.1', port, path: urlPath, method: httpMethod,
|
||||
headers: httpMethod === 'GET'
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (httpMethod !== 'GET') req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('routes/log-insights [DC-081]', () => {
|
||||
describe('_validateKeepDays', () => {
|
||||
const { _validateKeepDays } = logInsightsMod.__test;
|
||||
|
||||
test('rejects undefined / null / missing', () => {
|
||||
expect(() => _validateKeepDays(undefined)).toThrow(/required/i);
|
||||
expect(() => _validateKeepDays(null)).toThrow(/required/i);
|
||||
expect(() => _validateKeepDays()).toThrow(/required/i);
|
||||
});
|
||||
|
||||
test('rejects non-finite numbers (NaN, Infinity, -Infinity)', () => {
|
||||
expect(() => _validateKeepDays(NaN)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays(Infinity)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays(-Infinity)).toThrow(/finite/i);
|
||||
expect(() => _validateKeepDays('not-a-number')).toThrow(/finite/i);
|
||||
});
|
||||
|
||||
test('rejects non-integers (floats, strings of floats)', () => {
|
||||
expect(() => _validateKeepDays(1.5)).toThrow(/integer/i);
|
||||
expect(() => _validateKeepDays(30.7)).toThrow(/integer/i);
|
||||
expect(() => _validateKeepDays('30.5')).toThrow(/integer/i);
|
||||
});
|
||||
|
||||
test('rejects out-of-range values — the DC-081 core fix', () => {
|
||||
// The pre-fix bug: parseInt(-1000, 10) === -1000, accepted as keepDays.
|
||||
// cutoff = Date.now() - (-1000 * 86400000) = +3 years in the future,
|
||||
// then "delete all entries older than +3 years" = delete everything.
|
||||
expect(() => _validateKeepDays(-1)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(-1000)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(0)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(3651)).toThrow(/between 1 and 3650/i);
|
||||
expect(() => _validateKeepDays(1000000)).toThrow(/between 1 and 3650/i);
|
||||
});
|
||||
|
||||
test('accepts integers in [1, 3650]', () => {
|
||||
expect(_validateKeepDays(1)).toBe(1);
|
||||
expect(_validateKeepDays(30)).toBe(30);
|
||||
expect(_validateKeepDays(90)).toBe(90);
|
||||
expect(_validateKeepDays(365)).toBe(365);
|
||||
expect(_validateKeepDays(3650)).toBe(3650);
|
||||
});
|
||||
|
||||
test('coerces numeric strings', () => {
|
||||
expect(_validateKeepDays('30')).toBe(30);
|
||||
expect(_validateKeepDays('3650')).toBe(3650);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resolvePaths', () => {
|
||||
const { _resolvePaths } = logInsightsMod.__test;
|
||||
|
||||
test('falls back to platformPaths.dataDir when env unset', () => {
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
try {
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
// platformPaths.dataDir is /app/data in the container, /etc/dashcaddy on host
|
||||
expect(auditPath.endsWith('audit-log.json')).toBe(true);
|
||||
expect(secPath.endsWith('security-events.jsonl')).toBe(true);
|
||||
// Audit + security should land in the same data dir
|
||||
expect(path.dirname(auditPath)).toBe(path.dirname(secPath));
|
||||
} finally {
|
||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
|
||||
test('honours AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE env overrides', () => {
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
process.env.AUDIT_LOG_FILE = '/tmp/dc-081-audit.json';
|
||||
process.env.SECURITY_EVENT_LOG_FILE = '/tmp/dc-081-sec.jsonl';
|
||||
try {
|
||||
const { auditPath, secPath, auditPathFrom, secPathFrom } = _resolvePaths();
|
||||
expect(auditPath).toBe('/tmp/dc-081-audit.json');
|
||||
expect(secPath).toBe('/tmp/dc-081-sec.jsonl');
|
||||
expect(auditPathFrom).toBe('env');
|
||||
expect(secPathFrom).toBe('env');
|
||||
} finally {
|
||||
if (prevAudit === undefined) delete process.env.AUDIT_LOG_FILE;
|
||||
else process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec === undefined) delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
else process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
|
||||
test('matches the canonical paths used by audit-logger + event-store', async () => {
|
||||
// Sanity: load both modules' resolved paths and assert they match
|
||||
// what _resolvePaths returns. This catches a future refactor that
|
||||
// moves one but not the others (the bug class that produced DC-081).
|
||||
const prevAudit = process.env.AUDIT_LOG_FILE;
|
||||
const prevSec = process.env.SECURITY_EVENT_LOG_FILE;
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
try {
|
||||
const auditLoggerMod = require('../../src/security/audit-logger');
|
||||
const eventStoreMod = require('../../src/security/event-store');
|
||||
// Trigger event-store module-load (it captures ENV at require time)
|
||||
eventStoreMod.getStore();
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
// The audit-logger module exports a singleton; its private
|
||||
// AUDIT_LOG_FILE is not directly readable. Instead, we verify the
|
||||
// shape: both paths share the same dataDir and use the canonical
|
||||
// filenames.
|
||||
expect(path.basename(auditPath)).toBe('audit-log.json');
|
||||
expect(path.basename(secPath)).toBe('security-events.jsonl');
|
||||
// And the dirname matches platformPaths.dataDir
|
||||
const platformPaths = require('../../platform-paths');
|
||||
expect(path.dirname(auditPath)).toBe(platformPaths.dataDir);
|
||||
expect(path.dirname(secPath)).toBe(platformPaths.dataDir);
|
||||
// Also sanity that the singleton logger at least exists
|
||||
expect(auditLoggerMod).toBeDefined();
|
||||
} finally {
|
||||
if (prevAudit !== undefined) process.env.AUDIT_LOG_FILE = prevAudit;
|
||||
if (prevSec !== undefined) process.env.SECURITY_EVENT_LOG_FILE = prevSec;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /log-insights/dispose (TOTP-gated in production; here we hit the handler directly)', () => {
|
||||
let server;
|
||||
let app;
|
||||
let tmpDir;
|
||||
let auditFile;
|
||||
let secFile;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dc-081-'));
|
||||
auditFile = path.join(tmpDir, 'audit-log.json');
|
||||
secFile = path.join(tmpDir, 'security-events.jsonl');
|
||||
// Stage files so the route resolves them via env override.
|
||||
process.env.AUDIT_LOG_FILE = auditFile;
|
||||
process.env.SECURITY_EVENT_LOG_FILE = secFile;
|
||||
const router = buildRouter();
|
||||
app = makeApp(router);
|
||||
server = await start(app);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await stop(server);
|
||||
delete process.env.AUDIT_LOG_FILE;
|
||||
delete process.env.SECURITY_EVENT_LOG_FILE;
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function postKeepDays(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const port = server.address().port;
|
||||
const data = JSON.stringify(body);
|
||||
const req = require('http').request({
|
||||
hostname: '127.0.0.1', port, path: '/log-insights/dispose',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
try { resolve({ status: res.statusCode, body: JSON.parse(body) }); }
|
||||
catch (_) { resolve({ status: res.statusCode, body }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('rejects negative keepDays with 400 + DC-081_INVALID_KEEP_DAYS', async () => {
|
||||
const r = await postKeepDays({ keepDays: -1000 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
expect(r.body.error).toMatch(/between 1 and 3650/i);
|
||||
});
|
||||
|
||||
test('rejects 0 keepDays (no-op-but-lies)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 0 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects keepDays=Infinity (NaN-via-parseInt fallback)', async () => {
|
||||
const r = await postKeepDays({ keepDays: Infinity });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects non-integer keepDays', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30.5 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects missing keepDays', async () => {
|
||||
const r = await postKeepDays({});
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('rejects keepDays > 3650 (10-year cap)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 10000 });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.code).toBe('DC-081_INVALID_KEEP_DAYS');
|
||||
});
|
||||
|
||||
test('preview pass: returns wouldDelete count without writing', async () => {
|
||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString(); // 100 days ago
|
||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString(); // 5 days ago
|
||||
await fsp.writeFile(auditFile, JSON.stringify([
|
||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||
{ id: 'a2', timestamp: oldTs, action: 'service.delete' },
|
||||
{ id: 'a3', timestamp: newTs, action: 'auth.totp-verify' },
|
||||
]));
|
||||
await fsp.writeFile(secFile, [
|
||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's2', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's3', timestamp: newTs, severity: 'info' }),
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.preview).toBe(true);
|
||||
expect(r.body.wouldDelete.auditEntries).toBe(2);
|
||||
expect(r.body.wouldDelete.securityEvents).toBe(2);
|
||||
// Files untouched
|
||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(afterAudit.length).toBe(3);
|
||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean);
|
||||
expect(afterSec.length).toBe(3);
|
||||
});
|
||||
|
||||
test('confirm pass: actually deletes old entries, keeps new ones', async () => {
|
||||
const oldTs = new Date(Date.now() - 100 * 86400000).toISOString();
|
||||
const newTs = new Date(Date.now() - 5 * 86400000).toISOString();
|
||||
await fsp.writeFile(auditFile, JSON.stringify([
|
||||
{ id: 'a1', timestamp: oldTs, action: 'service.create' },
|
||||
{ id: 'a2', timestamp: newTs, action: 'auth.totp-verify' },
|
||||
]));
|
||||
await fsp.writeFile(secFile, [
|
||||
JSON.stringify({ id: 's1', timestamp: oldTs, severity: 'info' }),
|
||||
JSON.stringify({ id: 's2', timestamp: newTs, severity: 'info' }),
|
||||
].join('\n') + '\n');
|
||||
|
||||
const r = await postKeepDays({ keepDays: 30, confirm: true });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.disposed).toBe(true);
|
||||
expect(r.body.deleted.auditEntries).toBe(1);
|
||||
expect(r.body.deleted.securityEvents).toBe(1);
|
||||
expect(r.body.remaining.auditEntries).toBe(1);
|
||||
expect(r.body.remaining.securityEvents).toBe(1);
|
||||
|
||||
const afterAudit = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(afterAudit.map(e => e.id)).toEqual(['a2']);
|
||||
const afterSec = (await fsp.readFile(secFile, 'utf8')).split('\n').filter(Boolean).map(JSON.parse);
|
||||
expect(afterSec.map(e => e.id)).toEqual(['s2']);
|
||||
});
|
||||
|
||||
test('confirm=false treated as preview (not confirm)', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30, confirm: false });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.preview).toBe(true);
|
||||
// confirm was false, so no dispose
|
||||
expect(r.body.disposed).toBeUndefined();
|
||||
});
|
||||
|
||||
test('preview response includes resolved paths so operator knows what files will be touched', async () => {
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.paths.auditPath).toBe(auditFile);
|
||||
expect(r.body.paths.secPath).toBe(secFile);
|
||||
});
|
||||
|
||||
test('handles missing audit-log file gracefully on preview', async () => {
|
||||
await fsp.unlink(auditFile).catch(() => {});
|
||||
// fs.readFile().catch returns '[]', so preview reports 0 deletions
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body.wouldDelete.auditEntries).toBe(0);
|
||||
});
|
||||
|
||||
test('returns 500 DC-081_AUDIT_PARSE_FAILED on corrupt audit-log file', async () => {
|
||||
await fsp.writeFile(auditFile, 'this-is-not-json{');
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body.code).toBe('DC-081_AUDIT_PARSE_FAILED');
|
||||
});
|
||||
|
||||
test('returns 500 DC-081_AUDIT_SHAPE_INVALID if audit-log is a JSON object, not array', async () => {
|
||||
await fsp.writeFile(auditFile, JSON.stringify({ not: 'an array' }));
|
||||
const r = await postKeepDays({ keepDays: 30 });
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body.code).toBe('DC-081_AUDIT_SHAPE_INVALID');
|
||||
});
|
||||
|
||||
test('DC-081 CORE: pre-fix keptDays=-1000 no longer wipes everything', async () => {
|
||||
// Sanity-test the actual fix: a negative keepDays would, pre-fix,
|
||||
// compute a cutoff in the FUTURE and then delete everything. After
|
||||
// DC-081 it's a 400 with a clear error before any file read.
|
||||
const r = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||
expect(r.status).toBe(400);
|
||||
expect(r.body.success).toBe(false);
|
||||
// No file IO occurred — confirm that an unrelated existing audit
|
||||
// log file would survive. Since we already wiped tmpDir's auditFile
|
||||
// is empty, write a sentinel and confirm it's still there after.
|
||||
await fsp.writeFile(auditFile, JSON.stringify([{ id: 'sentinel', timestamp: new Date().toISOString() }]));
|
||||
const r2 = await postKeepDays({ keepDays: -1000, confirm: true });
|
||||
expect(r2.status).toBe(400);
|
||||
const after = JSON.parse(await fsp.readFile(auditFile, 'utf8'));
|
||||
expect(after.length).toBe(1);
|
||||
expect(after[0].id).toBe('sentinel');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,103 @@
|
||||
/**
|
||||
* DC-081: Plain-English log insights + dispose endpoint
|
||||
*
|
||||
* GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||
* POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||
*
|
||||
* DC-081 hardening (paired with the deploy path fix):
|
||||
* - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to
|
||||
* `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the
|
||||
* production container — files live at `/app/data/...`. The dispose
|
||||
* endpoint silently no-op'd (read empty arrays, wrote empty arrays
|
||||
* back) and the GET endpoint dropped the storage-size block. Both
|
||||
* paths now use the same canonical resolution as the audit-logger
|
||||
* itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`.
|
||||
* - keepDays was unbounded — `parseInt(req.body.keepDays) || 30` accepted
|
||||
* negative numbers (e.g. -1000 → cutoff = +3 years in the future,
|
||||
* deleting 100% of forensic context) and non-integers (Infinity,
|
||||
* floats). Now validated to an integer in [1, 3650] (1 day .. 10 years)
|
||||
* before any file read.
|
||||
* - confirm gate added: must send { confirm: true, keepDays: N } — the
|
||||
* preview pass is read-only, the confirm pass writes. Matches the
|
||||
* audit-logs/DELETE confirm=CLEAR pattern.
|
||||
* - The dispose handler now uses a single shared `_resolvePaths()` helper
|
||||
* to keep GET and POST in lockstep (and so a future path-config change
|
||||
* touches one site, not four).
|
||||
*
|
||||
* Pre-DC-081 verification: from inside the running container, both
|
||||
* `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl`
|
||||
* (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...`
|
||||
* paths resolve to ENOENT. The dispose endpoint therefore did nothing;
|
||||
* this fix wires it back to the actual files.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const platformPaths = require('../platform-paths');
|
||||
|
||||
/**
|
||||
* Resolve the canonical paths for the audit log + security event log.
|
||||
*
|
||||
* Both store the file path in their own module-level constants, so any
|
||||
* environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too —
|
||||
* exactly the same behaviour as src/security/audit-logger.js and
|
||||
* src/security/event-store.js. Without this, a container with
|
||||
* AUDIT_LOG_FILE set would see the dispose handler read from one file
|
||||
* and the audit-logger write to a different one.
|
||||
*
|
||||
* @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}}
|
||||
* paths + the source ("env" or "default") so tests can verify.
|
||||
*/
|
||||
function _resolvePaths() {
|
||||
const auditPath = process.env.AUDIT_LOG_FILE
|
||||
|| path.join(platformPaths.dataDir, 'audit-log.json');
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE
|
||||
|| path.join(platformPaths.dataDir, 'security-events.jsonl');
|
||||
return {
|
||||
auditPath,
|
||||
secPath,
|
||||
auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default',
|
||||
secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the keepDays input. Coerces + bounds-checks BEFORE any file
|
||||
* read so a malicious or mistyped client can't:
|
||||
* - pass a negative number (cutoff = far future → wipe 100%)
|
||||
* - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls
|
||||
* through `|| 30` — fixed to fail-fast instead)
|
||||
* - pass a non-integer (e.g. 1.5 → cutoff mid-day, off-by-half-day)
|
||||
* - pass 0 (no-op-but-lies) or 10000 (way past retention policy)
|
||||
*
|
||||
* @param {unknown} raw - value from req.body.keepDays
|
||||
* @returns {number} validated integer in [1, 3650]
|
||||
* @throws {Error} when out of range / wrong type
|
||||
*/
|
||||
function _validateKeepDays(raw) {
|
||||
if (raw === undefined || raw === null) {
|
||||
throw new Error('keepDays is required (integer in [1, 3650])');
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n)) {
|
||||
throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`);
|
||||
}
|
||||
if (!Number.isInteger(n)) {
|
||||
throw new Error(`keepDays must be an integer (received ${raw})`);
|
||||
}
|
||||
if (n < 1 || n > 3650) {
|
||||
throw new Error(`keepDays must be between 1 and 3650 (received ${n})`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
||||
const router = express.Router();
|
||||
// Resolve once at module init so GET + POST both use the same files.
|
||||
// If the env vars change at runtime (rare — start.sh wires them at
|
||||
// container start), operators re-deploy rather than mutate env mid-flight.
|
||||
const { auditPath, secPath } = _resolvePaths();
|
||||
|
||||
// GET /api/v1/log-insights — Plain English summary of who's doing what
|
||||
router.get('/log-insights', asyncHandler(async (req, res) => {
|
||||
@@ -74,16 +169,18 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
}
|
||||
|
||||
// --- Storage info ---
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
// DC-081: read from the canonical resolved paths (NOT the hardcoded
|
||||
// /opt/... paths that don't exist in the container). Empty-object
|
||||
// fallback on ENOENT — the file may legitimately be absent on a
|
||||
// fresh install where the audit-logger hasn't written yet.
|
||||
let storage = {};
|
||||
try {
|
||||
const a = await fs.stat(auditPath);
|
||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
||||
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath };
|
||||
} catch {}
|
||||
try {
|
||||
const s = await fs.stat(secPath);
|
||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
||||
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath };
|
||||
} catch {}
|
||||
|
||||
ok(res, {
|
||||
@@ -108,16 +205,44 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
}));
|
||||
|
||||
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
|
||||
//
|
||||
// Two-call pattern:
|
||||
// 1. { keepDays: 30 } → preview, no writes
|
||||
// 2. { keepDays: 30, confirm: true } → actually delete
|
||||
//
|
||||
// DC-081 hardening:
|
||||
// - keepDays is validated to integer [1, 3650] BEFORE any file read.
|
||||
// A negative keepDays (e.g. -1000) would previously compute a
|
||||
// cutoff +3 years in the future, then delete every entry older
|
||||
// than that — i.e. 100% of the audit log. Now rejected at the gate.
|
||||
// - auditPath / secPath come from the canonical _resolvePaths() helper
|
||||
// so the container's actual /app/data files are read (the pre-fix
|
||||
// hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to
|
||||
// ENOENT inside the container, so the endpoint silently did nothing).
|
||||
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
||||
const keepDays = parseInt(req.body.keepDays) || 30;
|
||||
// Validate keepDays first — fail-fast before any file IO so a bad
|
||||
// client never touches disk.
|
||||
let keepDays;
|
||||
try {
|
||||
keepDays = _validateKeepDays(req.body?.keepDays);
|
||||
} catch (e) {
|
||||
return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' });
|
||||
}
|
||||
const confirm = req.body.confirm === true;
|
||||
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
||||
|
||||
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
||||
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
||||
|
||||
// Read both files via the canonical resolved paths (NOT the hardcoded
|
||||
// /opt/... paths from before — those don't exist in the container).
|
||||
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
||||
const auditData = JSON.parse(auditRaw);
|
||||
let auditData;
|
||||
try {
|
||||
auditData = JSON.parse(auditRaw);
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' });
|
||||
}
|
||||
if (!Array.isArray(auditData)) {
|
||||
return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' });
|
||||
}
|
||||
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
|
||||
|
||||
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
|
||||
@@ -127,16 +252,40 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
if (!confirm) {
|
||||
ok(res, {
|
||||
preview: true,
|
||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
|
||||
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.',
|
||||
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
cutoffDate: cutoff
|
||||
cutoffDate: cutoff,
|
||||
paths: { auditPath, secPath },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute cleanup
|
||||
// Execute cleanup. Audit the wipe FIRST via the audit-logger so the
|
||||
// fact that a delete happened is itself preserved (matches the
|
||||
// audit-logs/DELETE + error-logs/DELETE pattern).
|
||||
try {
|
||||
if (auditLogger && typeof auditLogger.log === 'function') {
|
||||
await auditLogger.log({
|
||||
action: 'log-insights.dispose',
|
||||
resource: 'audit-log,security-events',
|
||||
outcome: 'success',
|
||||
details: {
|
||||
keepDays,
|
||||
cutoff,
|
||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { /* don't fail the dispose on audit-side errors */ }
|
||||
|
||||
// Rewrite audit-log.json atomically — write to tmp + rename so a
|
||||
// crash mid-write can't leave the file half-empty (the file is read
|
||||
// by state-manager on every container start; a corrupt file would
|
||||
// block the whole API).
|
||||
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
||||
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
||||
const tmpAudit = auditPath + '.tmp';
|
||||
await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2));
|
||||
await fs.rename(tmpAudit, auditPath);
|
||||
|
||||
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
|
||||
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
|
||||
@@ -145,9 +294,16 @@ module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore })
|
||||
disposed: true,
|
||||
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
||||
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
|
||||
cutoffDate: cutoff
|
||||
cutoffDate: cutoff,
|
||||
});
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// DC-081: export helpers for direct unit testing (the route handlers are
|
||||
// otherwise unreachable from outside the factory closure).
|
||||
module.exports.__test = {
|
||||
_resolvePaths,
|
||||
_validateKeepDays,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user