DC-079 2-round GLM-5.3 judge verdict: round1=C (blocking path-traversal
in assets/themes) → round2=A. 20/20 tests in routes/discover-disaster
(8 original + 12 new). Full repo: 2351/2351 (4 pre-existing billing
pdfkit failures unchanged).
THREAT MODEL
POST /api/v1/disaster/restore was the ONLY endpoint in the route tree
that wrote directly to process.env.CADDYFILE_PATH (=/caddyfile in
container = /etc/caddy/Caddyfile on host via start.sh:161 bind-mount).
Pre-fix: an authenticated dashboard operator POSTed
{caddyfile: '<attacker-controlled-string>'}
and the handler called fsp.writeFile(caddyfilePath, snapshot.caddyfile),
overwriting the live Caddyfile immediately. Caddy reads this file on
every reload (ACME renewal, health probe, admin API touch), so the
attacker-controlled content executes as Caddy config directives:
- import /etc/caddy/<anything-caddy-can-read> (content theft)
- admin off (lock out admin API)
- reverse_proxy to attacker IPs (Caddy becomes a pivot)
- acme_ca override to attacker CA (rogue cert issuance)
- log to attacker-writable paths (DoS/escape)
This bypassed the CLAUDE.md hard rule 'Caddyfile edits must use
caddy-apply' (validates + reloads + git-commits atomically).
FIX 1 — Caddyfile staging (round-1)
- New validateCaddyfileContent(): type check, non-empty check,
512 KiB byte cap (defense-in-depth below the 1 MB body-parser limit),
FORBIDDEN_IMPORT_RE rejects directives with absolute paths,
../-escape, ~/, or URL-encoded payloads.
- POST /disaster/restore now writes to <dataDir>/disaster-staged/
Caddyfile.candidate (atomic write + rename), NEVER to caddyfilePath.
- Response includes caddyfileStaged[{file, stagedPath, action: 'awaiting
caddy-apply', livePath}] and a DC-079 warning instructing the operator
to run `caddy-apply <reason>` to validate + reload + git-commit.
FIX 2 — assets/themes path-traversal (round-2 BLOCKING)
GLM round-1 caught a parallel vector: snapshot.assets[name] and
snapshot.themes[name] are user-controlled JSON keys flowing into
path.join(assetsDir, name) and path.join(themesDir, name). An attacker
could POST {assets: {'../../etc/caddy/Caddyfile': '<base64-evil>'}}
and overwrite the live Caddyfile via the dataDir bind-mount, fully
bypassing Fix 1.
- ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/ + ASSET_PATH_TRAVERSAL_RE catch
slashes, leading '..', and absolute-path keys.
- THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json\$/ additionally forces
.json extension and no slashes.
- assertSafeAssetKey/assertSafeThemeName helpers throw on invalid input.
- Both restore loops now: assert → path.resolve(dir, name) → containment
check (resolved must start with path.resolve(dir) + path.sep) → write
to resolved (never the raw join).
TESTS
12 new tests in __tests__/routes/discover-disaster.routes.test.js:
- staging: live sentinel unchanged, candidate at expected path
- rejects: non-string, empty, oversize, 3 forbidden-import variants
- assets: path-traversal key, absolute-path key
- themes: path-traversal name, no-extension name
- back-compat: no caddyfile field succeeds without staging
406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
/**
|
|
* DC-100: Service discovery + DC-107: Disaster recovery endpoint tests
|
|
*/
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
function createDiscoverApp(docker, servicesStateManager) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
const routes = require('../../routes/discover');
|
|
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
|
app.use('/api/v1', routes({ docker, servicesStateManager, asyncHandler: wrap }));
|
|
return app;
|
|
}
|
|
|
|
function createDisasterApp(platformPaths, log) {
|
|
const app = express();
|
|
// 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 }));
|
|
return app;
|
|
}
|
|
|
|
describe('DC-100: Service Discovery', () => {
|
|
it('returns 503 when Docker is not available', async () => {
|
|
const app = createDiscoverApp(null, null);
|
|
const res = await request(app).get('/api/v1/discover');
|
|
expect(res.status).toBe(503);
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
|
|
it('discovers running containers with pattern matching', async () => {
|
|
const mockDocker = {
|
|
client: {
|
|
listContainers: jest.fn().mockResolvedValue([
|
|
{
|
|
Id: 'abc123def456',
|
|
Names: ['/plex-server'],
|
|
Image: 'plexinc/pms-docker:latest',
|
|
State: 'running',
|
|
Ports: [{ IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' }],
|
|
Labels: {},
|
|
},
|
|
]),
|
|
},
|
|
};
|
|
|
|
const app = createDiscoverApp(mockDocker, { read: jest.fn().mockResolvedValue([]) });
|
|
const res = await request(app).get('/api/v1/discover');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.total).toBe(1);
|
|
expect(res.body.discovered[0].suggested.type).toBe('plex');
|
|
});
|
|
|
|
it('handles empty container list', async () => {
|
|
const app = createDiscoverApp({ client: { listContainers: jest.fn().mockResolvedValue([]) } }, null);
|
|
const res = await request(app).get('/api/v1/discover');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.total).toBe(0);
|
|
});
|
|
|
|
it('returns 500 on Docker error', async () => {
|
|
const app = createDiscoverApp({ client: { listContainers: jest.fn().mockRejectedValue(new Error('fail')) } }, null);
|
|
const res = await request(app).get('/api/v1/discover');
|
|
expect(res.status).toBe(500);
|
|
});
|
|
});
|
|
|
|
describe('DC-107: Disaster Recovery', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-dr-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('GET /disaster/status returns empty status initially', async () => {
|
|
const app = createDisasterApp({ dataDir: tmpDir });
|
|
const res = await request(app).get('/api/v1/disaster/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.lastBackup).toBeTruthy();
|
|
expect(res.body.lastBackup.status).toBeNull();
|
|
});
|
|
|
|
it('POST /disaster/backup creates snapshot', async () => {
|
|
// Create a services.json so backup has data
|
|
fs.writeFileSync(path.join(tmpDir, 'services.json'), JSON.stringify([{ id: 'test' }]));
|
|
fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify({ tld: '.sami' }));
|
|
|
|
const app = createDisasterApp({ dataDir: tmpDir });
|
|
const res = await request(app).post('/api/v1/disaster/backup');
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.version).toBe('1.0');
|
|
expect(res.body.files.services).toBeTruthy();
|
|
expect(res.body.files.config).toBeTruthy();
|
|
expect(res.body.checksum).toBeTruthy();
|
|
});
|
|
|
|
it('POST /disaster/restore rejects invalid snapshot', async () => {
|
|
const app = createDisasterApp({ dataDir: tmpDir });
|
|
const res = await request(app)
|
|
.post('/api/v1/disaster/restore')
|
|
.send({ foo: 'bar' });
|
|
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it('POST /disaster/restore restores files', async () => {
|
|
const app = createDisasterApp({ dataDir: tmpDir });
|
|
const res = await request(app)
|
|
.post('/api/v1/disaster/restore')
|
|
.send({
|
|
version: '1.0',
|
|
files: {
|
|
services: [{ id: 'restored-svc' }],
|
|
config: { tld: '.test' },
|
|
},
|
|
});
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.status).toBe('success');
|
|
expect(res.body.restored).toContain('services.json');
|
|
expect(res.body.restored).toContain('config.json');
|
|
|
|
// Verify files were written
|
|
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/);
|
|
});
|
|
});
|