/** * DC-107: Disaster Recovery — one-click backup + restore of entire DashCaddy setup * * Creates a complete system snapshot including: * - All services config (services.json) * - DashCaddy config (config.json) * - Encrypted credentials (credentials.json) * - Caddyfile * - DNS credentials * - Custom themes, logo, favicon * - Notification config * - Audit log * * Excludes: Docker images, container data volumes (too large for API) * * POST /api/v1/disaster/backup — create full snapshot (returns download) * POST /api/v1/disaster/restore — restore from uploaded snapshot * GET /api/v1/disaster/status — check last backup/restore status */ const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); const crypto = require('crypto'); const { ok, errorResponse } = require('../src/utils/responses'); const { ErrorCodes } = require('../src/utilities/error-codes'); // Files that make up a complete DashCaddy backup const BACKUP_FILES = [ { key: 'services', path: 'services.json', required: true }, { key: 'config', path: 'config.json', required: true }, { key: 'credentials', path: 'credentials.json', required: false }, { key: 'dnsCredentials', path: 'dns-credentials.json', required: false }, { key: 'notifications', path: 'notifications.json', required: false }, { key: 'auditLog', path: 'audit-log.json', required: false }, ]; 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": // ""}}` 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 /themes/; 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 ` 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(); 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. */ router.post('/disaster/backup', wrap(async (req, res) => { const dataDir = platformPaths?.dataDir || '/app/data'; const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile'; const snapshot = { version: '1.0', createdAt: new Date().toISOString(), hostname: require('os').hostname(), dashcaddyVersion: process.env.npm_package_version || 'unknown', files: {}, assets: {}, caddyfile: null, }; // Collect config files for (const { key, path: filePath, required } of BACKUP_FILES) { const fullPath = path.join(dataDir, filePath); try { const content = await fsp.readFile(fullPath, 'utf8'); snapshot.files[key] = JSON.parse(content); } catch (err) { if (required) { return errorResponse(res, 500, `Required file missing: ${filePath}`, { code: ErrorCodes.BACKUP.BACKUP_FAILED, }); } // Optional file — skip } } // Collect Caddyfile try { snapshot.caddyfile = await fsp.readFile(caddyfilePath, 'utf8'); } catch { // Caddyfile not accessible — continue without it } // Collect assets (logo, favicon) const assetsDir = platformPaths?.resolveAssetsPath?.() || path.join(dataDir, 'assets'); for (const assetName of ASSET_FILES) { const assetPath = path.join(assetsDir, assetName); try { const data = await fsp.readFile(assetPath); snapshot.assets[assetName] = data.toString('base64'); } catch { // Asset doesn't exist — skip } } // Collect themes try { const themesDir = path.join(dataDir, 'themes'); const themes = await fsp.readdir(themesDir); snapshot.themes = {}; for (const theme of themes) { if (theme.endsWith('.json')) { const content = await fsp.readFile(path.join(themesDir, theme), 'utf8'); snapshot.themes[theme] = JSON.parse(content); } } } catch { // No themes directory } // Generate checksum for integrity verification const snapshotJson = JSON.stringify(snapshot); snapshot.checksum = crypto.createHash('sha256').update(snapshotJson).digest('hex'); lastBackupStatus = { timestamp: snapshot.createdAt, status: 'success', size: Buffer.byteLength(snapshotJson), }; if (log) log.info('disaster-recovery', 'Backup created', { size: lastBackupStatus.size }); // Send as downloadable file const filename = `dashcaddy-backup-${new Date().toISOString().split('T')[0]}.json`; res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.json(snapshot); })); /** * POST /api/v1/disaster/restore * Restores from an uploaded snapshot JSON. * Body: { snapshot: {...} } or raw JSON snapshot */ router.post('/disaster/restore', wrap(async (req, res) => { const dataDir = platformPaths?.dataDir || '/app/data'; const caddyfilePath = process.env.CADDYFILE_PATH || '/caddyfile'; let snapshot = req.body?.snapshot || req.body; if (!snapshot || !snapshot.version) { return errorResponse(res, 400, 'Invalid snapshot: missing version field', { code: ErrorCodes.BACKUP.INVALID_CONFIG, }); } // Verify checksum if present if (snapshot.checksum) { const expectedChecksum = snapshot.checksum; const { checksum, ...rest } = snapshot; const actualChecksum = crypto.createHash('sha256').update(JSON.stringify(rest)).digest('hex'); if (expectedChecksum !== actualChecksum) { return errorResponse(res, 400, 'Snapshot checksum mismatch — file may be corrupted', { code: ErrorCodes.BACKUP.INVALID_CONFIG, }); } } const restored = []; const errors = []; // Restore config files for (const { key, path: filePath } of BACKUP_FILES) { if (!snapshot.files?.[key]) continue; try { const fullPath = path.join(dataDir, filePath); await fsp.writeFile(fullPath, JSON.stringify(snapshot.files[key], null, 2)); restored.push(filePath); } catch (err) { errors.push({ file: filePath, error: err.message }); } } // 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: ` // 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/` 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.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 (staging)', error: err.message }); } } // Restore assets 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(resolved, Buffer.from(base64, 'base64')); restored.push(`assets/${name}`); } catch (err) { errors.push({ file: `assets/${name}`, error: err.message }); } } // Restore themes if (snapshot.themes) { const themesDir = path.join(dataDir, 'themes'); try { await fsp.mkdir(themesDir, { recursive: true }); for (const [name, content] of Object.entries(snapshot.themes)) { // 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 }); } } lastRestoreStatus = { 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); // 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${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 ` (or equivalent) to validate + reload + git-commit the staged candidate.'; } ok(res, responseBody); })); /** * GET /api/v1/disaster/status */ router.get('/disaster/status', wrap(async (req, res) => { ok(res, { lastBackup: lastBackupStatus, lastRestore: lastRestoreStatus }); })); return router; };