Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99ec6ebc53 | ||
|
|
a7260436d1 |
@@ -18,7 +18,11 @@ function createDiscoverApp(docker, servicesStateManager) {
|
|||||||
|
|
||||||
function createDisasterApp(platformPaths, log) {
|
function createDisasterApp(platformPaths, log) {
|
||||||
const app = express();
|
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 routes = require('../../routes/disaster-recovery');
|
||||||
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
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 }));
|
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'));
|
const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8'));
|
||||||
expect(svc[0].id).toBe('restored-svc');
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -131,6 +131,20 @@ describe('routes/tailscale-admin: PUT /settings', () => {
|
|||||||
expect(res.status).toBe(400);
|
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 () => {
|
test('200 + saves token + writes metadata on valid token', async () => {
|
||||||
const fakeClient = makeFakeClient({
|
const fakeClient = makeFakeClient({
|
||||||
ping: jest.fn(async () => ({ domain: 'real.ts.net' })),
|
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(res.body.valid).toBe(true);
|
||||||
expect(fakeClient.setApiToken).toHaveBeenCalledWith('tskey-api-test-only');
|
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', () => {
|
describe('routes/tailscale-admin: GET /admin/devices', () => {
|
||||||
@@ -511,6 +595,99 @@ describe('routes/tailscale-admin: pre-auth keys', () => {
|
|||||||
expect(res.status).toBe(400);
|
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 () => {
|
test('POST /admin/keys rejects negative expirySeconds', async () => {
|
||||||
const fakeClient = makeFakeClient();
|
const fakeClient = makeFakeClient();
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -573,3 +750,109 @@ describe('routes/tailscale-admin: security boundary', () => {
|
|||||||
expect(stored.token).toBeNull();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,81 @@ const BACKUP_FILES = [
|
|||||||
|
|
||||||
const ASSET_FILES = ['custom-logo.png', 'custom-favicon.png', 'custom-logo.svg'];
|
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 }) {
|
module.exports = function({ servicesStateManager, platformPaths, log, asyncHandler }) {
|
||||||
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -44,6 +119,15 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
let lastBackupStatus = { timestamp: null, status: null, size: null };
|
||||||
let lastRestoreStatus = { timestamp: null, status: 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
|
* POST /api/v1/disaster/backup
|
||||||
* Creates a complete system snapshot as a downloadable JSON file.
|
* Creates a complete system snapshot as a downloadable JSON file.
|
||||||
@@ -175,13 +259,64 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore Caddyfile
|
// DC-079: Stage the Caddyfile to a staging path inside dataDir
|
||||||
if (snapshot.caddyfile) {
|
// 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 {
|
try {
|
||||||
await fsp.writeFile(caddyfilePath, snapshot.caddyfile);
|
await fsp.mkdir(stagedDir, { recursive: true });
|
||||||
restored.push('Caddyfile');
|
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) {
|
} 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');
|
const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets');
|
||||||
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
for (const [name, base64] of Object.entries(snapshot.assets || {})) {
|
||||||
try {
|
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.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}`);
|
restored.push(`assets/${name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push({ file: `assets/${name}`, error: err.message });
|
errors.push({ file: `assets/${name}`, error: err.message });
|
||||||
@@ -203,8 +350,21 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
try {
|
try {
|
||||||
await fsp.mkdir(themesDir, { recursive: true });
|
await fsp.mkdir(themesDir, { recursive: true });
|
||||||
for (const [name, content] of Object.entries(snapshot.themes)) {
|
for (const [name, content] of Object.entries(snapshot.themes)) {
|
||||||
await fsp.writeFile(path.join(themesDir, name), JSON.stringify(content, null, 2));
|
// DC-079: same path-traversal vector as assets — keys are
|
||||||
restored.push(`themes/${name}`);
|
// 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) {
|
} catch (err) {
|
||||||
errors.push({ file: 'themes', error: err.message });
|
errors.push({ file: 'themes', error: err.message });
|
||||||
@@ -215,19 +375,33 @@ module.exports = function({ servicesStateManager, platformPaths, log, asyncHandl
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
status: errors.length === 0 ? 'success' : 'partial',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored: restored.length,
|
restored: restored.length,
|
||||||
|
staged: caddyfileStaged.length,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (log) log.info('disaster-recovery', 'Restore completed', lastRestoreStatus);
|
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',
|
status: errors.length === 0 ? 'success' : 'partial',
|
||||||
restored,
|
restored,
|
||||||
errors,
|
errors,
|
||||||
message: errors.length === 0
|
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.`,
|
: `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);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -41,12 +41,124 @@
|
|||||||
*
|
*
|
||||||
* DELETE /api/v1/tailscale/admin/devices/:id
|
* DELETE /api/v1/tailscale/admin/devices/:id
|
||||||
* Revokes a device from the tailnet.
|
* 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 express = require('express');
|
||||||
const { ok, errorResponse } = require('../src/utils/responses');
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
const { TailscaleCoordError } = require('../src/managers/tailscale-coord');
|
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({
|
module.exports = function({
|
||||||
tailscaleCoord,
|
tailscaleCoord,
|
||||||
asyncHandler,
|
asyncHandler,
|
||||||
@@ -75,9 +187,12 @@ module.exports = function({
|
|||||||
|
|
||||||
router.put('/settings', asyncHandler(async (req, res) => {
|
router.put('/settings', asyncHandler(async (req, res) => {
|
||||||
const token = req.body && req.body.apiToken;
|
const token = req.body && req.body.apiToken;
|
||||||
if (!token || typeof token !== 'string' || !token.startsWith('tskey-api-')) {
|
// DC-080: validate prefix + length cap. The pre-fix code only checked
|
||||||
return errorResponse(res, 400, 'Invalid API token (must start with tskey-api-)');
|
// 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
|
// Validate before storing
|
||||||
const client = new (require('../src/managers/tailscale-coord').TailscaleCoordClient)({ apiToken: token });
|
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) => {
|
router.post('/settings/test', asyncHandler(async (req, res) => {
|
||||||
const token = (req.body && req.body.apiToken) || null;
|
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();
|
const client = await tailscaleCoord.getClient();
|
||||||
if (token) {
|
if (token) {
|
||||||
// Caller provided a fresh token to test — don't save it
|
// 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');
|
return errorResponse(res, 503, 'Tailscale API token not configured');
|
||||||
}
|
}
|
||||||
const opts = req.body || {};
|
const opts = req.body || {};
|
||||||
// Reject obviously-bad input early
|
// Reject obviously-bad input early.
|
||||||
if (opts.tags && !Array.isArray(opts.tags)) {
|
// DC-080: pre-fix the route only checked `Array.isArray(opts.tags)`.
|
||||||
return errorResponse(res, 400, 'tags must be an array of strings');
|
// 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)) {
|
if (opts.expirySeconds !== undefined && (!Number.isInteger(opts.expirySeconds) || opts.expirySeconds <= 0)) {
|
||||||
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
return errorResponse(res, 400, 'expirySeconds must be a positive integer');
|
||||||
}
|
}
|
||||||
@@ -255,3 +387,9 @@ module.exports = function({
|
|||||||
|
|
||||||
return router;
|
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;
|
||||||
Reference in New Issue
Block a user