Files
picpeak/backend/src/routes/adminRestore.js
T
Paul NothaftandPaul Nothaft 0d4c30884e fix(security): backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (#956)
* fix(security): stop caller-chosen database backup destination (GHSA-jw8m)

POST /api/admin/database-backup/backup forwarded req.body straight into
databaseBackupService.backup(), which merges options over config:
  const { destinationPath = '/backup/database', ... } = { ...config, ...options }

destinationPath is not a persistable setting — the /config allowlist only
accepts database_backup_* keys — so the request body was its only source.
The built-in `admin` role holds backup.create but neither settings.edit nor
backup.restore, so it could aim a full DB dump (admin bcrypt hashes, gallery
password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
(server.js mounts it with no auth middleware) and then fetch it
unauthenticated. Filed low; it is a privilege escalation to unauthenticated
disclosure.

Forward only the real knobs, and only when present so absent keys can't
override config defaults via spread.

* fix(security): backup/restore hardening — restore path allowlist, gunzip bound, manifest checksum keying (GHSA-fw4c, h652, hgp8)

- adminRestore /validate + /start: constrain caller-supplied source and
  manifestPath to the operator-configured backup roots — the SAME set the
  restore wizard discovers from — so disaster recovery from a rescued mount
  still works, with RESTORE_ALLOWED_ROOTS as an escape hatch (GHSA-fw4c).
- restoreService.decompressFile: bound the EXPANDED size and abort the
  pipeline when exceeded; default 50 GB, RESTORE_MAX_DECOMPRESSED_BYTES
  overrides (GHSA-h652).
- backupManifest: BACKUP_MANIFEST_KEY upgrades new manifests to a keyed
  HMAC (GHSA-hgp8). Deliberately opt-in and verify-if-present — the key
  cannot live in the database because the database is inside the backup, so
  a mandatory HMAC would lock operators out of the exact disaster-recovery
  case this exists for.

Also fixes a pre-existing bug found while testing hgp8: the checksum passed
Object.keys().sort() as JSON.stringify's second argument, which is an array
REPLACER (a property allowlist applied at every depth), not a key sorter. All
nested keys — path, size, per-file checksum — were dropped before hashing, so
the file list sat outside the integrity check entirely and a manifest path
could be rewritten to ../../etc/passwd without disturbing the digest. Now
hashes a recursively-canonicalized copy, with the legacy serialization
accepted on validation so existing backups stay restorable.

* fix(security): codex round 2 — unbreak the restore wizard, share checksum verification, guard downgrades

- adminRestore: `source` is usually a SOURCE TYPE ('local'|'s3'|'upload'),
  not a path — restoreService branches on those literals. The containment
  check treated it as a path, so path.resolve('local') fell outside the
  backup roots and BOTH /validate and /start returned 400, blocking every
  normal restore. Type tokens are now excluded from the path check.
- backupManifest: extracted verifyManifestChecksum() as the single source of
  truth for the legacy/keyed fallbacks. restoreService.performPreRestoreValidation
  recomputed the digest itself with the default canonical+keyed settings,
  which rejected EVERY backup written before this batch. It now delegates.
- backupManifest: guard the algorithm downgrade — with a key configured, an
  attacker able to rewrite the backup store could strip checksum_algorithm,
  edit the manifest and recompute a plain SHA-256 that verified. Opt-in via
  BACKUP_MANIFEST_REQUIRE_KEYED so pre-key backups keep restoring by default.

* fix(security): codex round 3 — close two manifest-verification fail-opens (GHSA-hgp8)

verifyManifestChecksum returned valid for a manifest with no
verification.total_checksum at all, and restoreService only called it when
that field was present. Deleting the field was therefore a complete bypass of
the keying work: no digest check, no downgrade guard, no
BACKUP_MANIFEST_REQUIRE_KEYED. Every manifest this codebase writes stamps the
field, so an absent one now fails validation, and the call site invokes the
verifier unconditionally.

Second fail-open: the strict-mode rejection of an unkeyed manifest was gated on
`&& key`, so with BACKUP_MANIFEST_REQUIRE_KEYED=true and no BACKUP_MANIFEST_KEY
configured a plain SHA-256 manifest sailed through. Strict mode is a statement
about the operator's manifests, not about the host — it is exactly the fresh
disaster-recovery box that lacks the secret. The rejection no longer depends on
a key being present.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me

---------

Co-authored-by: Paul Nothaft <[email protected]>
2026-08-02 21:17:05 +02:00

838 lines
27 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
/**
* Admin routes for restore operations
* All routes require admin authentication
*/
// Apply admin authentication to all routes
router.use(adminAuth);
/**
* Transform frontend S3 config to backend format
* Frontend sends: s3Endpoint, s3Bucket, s3AccessKey, s3SecretKey, s3Region
* Backend expects: endpoint, bucket, accessKeyId, secretAccessKey, region
*/
function transformS3Config(body) {
if (body.s3Config) {
// Already in correct format
return body.s3Config;
}
// Check if frontend sent flat S3 config fields
if (body.s3Endpoint || body.s3Bucket || body.s3AccessKey || body.s3SecretKey) {
return {
endpoint: body.s3Endpoint,
bucket: body.s3Bucket,
accessKeyId: body.s3AccessKey,
secretAccessKey: body.s3SecretKey,
region: body.s3Region || 'us-east-1',
forcePathStyle: body.s3ForcePathStyle !== false
};
}
return null;
}
/**
* Get restore service status and history
*/
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
const { limit } = getPagination(req, { limit: 10 });
const history = await restoreService.getRestoreHistory(limit);
const status = {
isRunning: restoreService.isRunning,
currentProgress: restoreService.getProgress(),
history: history,
settings: await getRestoreSettings()
};
res.json({
success: true,
data: status
});
} catch (error) {
logger.error('Failed to get restore status:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore status'
});
}
});
/**
* Validate restore request
*/
router.post('/validate', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
body('selectedItems').optional().isArray(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Perform dry run validation
const result = await restoreService.restore({
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
s3Config,
dryRun: true,
force: false
});
// Transform spaceCheck to match frontend expected format
const spaceCheck = result.spaceCheck ? {
sufficient: result.spaceCheck.hasEnoughSpace,
required: result.spaceCheck.requiredBytes,
available: result.spaceCheck.availableBytes,
requiredFormatted: result.spaceCheck.requiredFormatted,
availableFormatted: result.spaceCheck.availableFormatted,
// Keep original fields for backwards compatibility
hasEnoughSpace: result.spaceCheck.hasEnoughSpace,
requiredBytes: result.spaceCheck.requiredBytes,
availableBytes: result.spaceCheck.availableBytes
} : null;
res.json({
success: true,
data: {
validation: result.validation,
spaceCheck,
logs: result.logs
}
});
} catch (error) {
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: error.message || 'Restore validation failed',
logs: restoreService.restoreLog
});
}
});
/**
* Start restore operation
*/
router.post('/start', requirePermission('backup.restore'), [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
body('selectedItems').optional().isArray(),
body('skipPreBackup').optional().isBoolean(),
body('force').optional().isBoolean(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Check if restore is already running
if (restoreService.isRunning) {
return res.status(409).json({
success: false,
error: 'Restore operation already in progress'
});
}
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Check permissions for dangerous options
const settings = await getRestoreSettings();
if (req.body.force && !settings.restore_allow_force) {
return res.status(403).json({
success: false,
error: 'Force restore is not allowed by system settings'
});
}
if (req.body.skipPreBackup && settings.restore_require_pre_backup) {
return res.status(403).json({
success: false,
error: 'Skipping pre-restore backup is not allowed by system settings'
});
}
// Log restore attempt
logger.warn('Restore operation started', {
user: req.admin.email,
ip: req.ip,
restoreType: req.body.restoreType,
source: req.body.source
});
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
// Start restore in background
restoreService.restore({
source: req.body.source,
manifestPath: req.body.manifestPath,
restoreType: req.body.restoreType,
selectedItems: req.body.selectedItems,
skipPreBackup: req.body.skipPreBackup,
force: req.body.force,
s3Config,
dryRun: false,
operator: {
type: 'manual',
userId: req.admin.id,
ip: req.ip
}
}).catch(error => {
logger.error('Background restore failed:', error);
});
res.json({
success: true,
message: 'Restore operation started'
});
} catch (error) {
logger.error('Failed to start restore:', error);
logger.error('Error stack:', error.stack);
res.status(500).json({
success: false,
error: error.message || 'Failed to start restore operation'
});
}
});
/**
* Get current restore progress
*/
router.get('/progress', requirePermission('backup.view'), async (req, res) => {
try {
const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
res.json({
success: true,
data: {
isRunning: restoreService.isRunning,
progress: progress,
logs: logs
}
});
} catch (error) {
logger.error('Failed to get restore progress:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore progress'
});
}
});
/**
* Get restore run details
*/
router.get('/run/:id', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
if (run.metadata) run.metadata = JSON.parse(run.metadata);
// Get validation results
const validations = await db('restore_validation_results')
.where('restore_run_id', run.id)
.select('*');
validations.forEach(v => {
if (v.errors) v.errors = JSON.parse(v.errors);
if (v.warnings) v.warnings = JSON.parse(v.warnings);
if (v.checksums) v.checksums = JSON.parse(v.checksums);
});
// Get file operations summary
const fileOps = await db('restore_file_operations')
.where('restore_run_id', run.id)
.select('status', db.raw('COUNT(*) as count'))
.groupBy('status');
res.json({
success: true,
data: {
run: run,
validations: validations,
fileOperations: fileOps
}
});
} catch (error) {
logger.error('Failed to get restore run details:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore run details'
});
}
});
/**
* Get restore run report
*/
router.get('/run/:id/report', requirePermission('backup.view'), async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
// Generate report
const report = restoreService.generateRestoreReport({
success: run.status === 'completed',
duration: run.duration_seconds,
dryRun: run.is_dry_run,
result: run.statistics,
logs: run.restore_log || []
});
res.type('text/plain').send(report);
} catch (error) {
logger.error('Failed to generate restore report:', error);
res.status(500).json({
success: false,
error: 'Failed to generate restore report'
});
}
});
/**
* List available backups for restore
*/
router.get('/available-backups', requirePermission('backup.view'), async (req, res) => {
try {
const backups = await discoverAvailableBackups();
res.json({
success: true,
data: backups
});
} catch (error) {
logger.error('Failed to list available backups:', error);
res.status(500).json({
success: false,
error: 'Failed to list available backups'
});
}
});
/**
* Discover restorable backups by walking the configured destination
* directory + harvesting the backup_runs table.
*
* **Why we recurse the disk first, DB second**
*
* The disk is the source of truth for restore. After a disaster
* (`docker compose down -v`, drive corruption, fresh install) the
* `backup_runs` table is empty — but the manifest JSONs are exactly
* what's left on disk for an admin to recover from. A wizard that
* only reads the DB shows "No backups found" precisely when the
* admin needs it most. So we walk first, dedupe-by-manifestId
* against any surviving DB rows, and present a unified list.
*
* Discovery rules:
* - Walks `backup_destination_path` AND `backup_manifest_path` if
* they're distinct (manifests can live in a sibling directory).
* - Recurses up to 3 levels deep — enough to find
* `<root>/manifests/backup-manifest-<id>.json` (the default
* layout) without scanning the entire photo tree.
* - Matches manifest files by glob: `backup-manifest-*.json`,
* `backup-manifest-*.yaml`, and the legacy bare `manifest.json`.
* - Parses each manifest to extract real metadata (timestamp,
* size, file count, source type) instead of showing the admin
* a list of opaque filenames.
*
* Returns: array of `{ type, name, path, manifestId, size,
* filesCount, completed, source: 'disk' | 'db' }`.
*/
async function discoverAvailableBackups() {
const backupConfig = await getBackupConfig();
const backups = [];
const seenManifestIds = new Set();
if (backupConfig.backup_destination_type === 'local') {
const roots = new Set();
if (backupConfig.backup_destination_path) roots.add(backupConfig.backup_destination_path);
if (backupConfig.backup_manifest_path) roots.add(backupConfig.backup_manifest_path);
for (const root of roots) {
try {
const manifestPaths = await walkForManifests(root, 3);
for (const filePath of manifestPaths) {
try {
const parsed = await parseManifestMetadata(filePath);
if (parsed.manifestId) seenManifestIds.add(parsed.manifestId);
backups.push(parsed);
} catch (err) {
// Don't fail discovery because ONE manifest is corrupt —
// surface the file with a note so the admin sees something
// is wrong and can investigate.
logger.warn(`Manifest unreadable at ${filePath}: ${err.message}`);
const stats = await fs.stat(filePath).catch(() => null);
backups.push({
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId: null,
size: stats?.size || 0,
filesCount: null,
completed: stats?.mtime || null,
source: 'disk',
corrupt: true,
error: err.message,
});
}
}
} catch (err) {
logger.warn(`Could not scan backup root ${root}: ${err.message}`);
}
}
}
// Layer in surviving DB rows, deduping by manifest_id so we don't
// show the same backup twice with different shapes.
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(50);
for (const run of backupRuns) {
if (run.manifest_id && seenManifestIds.has(run.manifest_id)) continue;
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at,
source: 'db',
});
}
// Most recent first.
backups.sort((a, b) => {
const aTime = a.completed ? new Date(a.completed).getTime() : 0;
const bTime = b.completed ? new Date(b.completed).getTime() : 0;
return bTime - aTime;
});
return backups;
}
/**
* Recursive manifest finder. Depth-limited so we don't enumerate
* thousands of photo files. Yields absolute paths.
*/
async function walkForManifests(dir, maxDepth, depth = 0) {
if (depth > maxDepth) return [];
let entries;
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
const out = [];
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Skip obvious noise: photo trees, node_modules, hidden dirs.
if (entry.name === 'events' || entry.name === 'business-docs'
|| entry.name === 'thumbnails' || entry.name === 'previews'
|| entry.name === 'heroes' || entry.name === 'uploads'
|| entry.name.startsWith('.')
|| entry.name === 'node_modules') continue;
out.push(...await walkForManifests(full, maxDepth, depth + 1));
} else if (entry.isFile() && isManifestFilename(entry.name)) {
out.push(full);
}
}
return out;
}
function isManifestFilename(name) {
// Canonical: backup-manifest-<id>.json / .yaml
// Legacy: manifest.json (inside backup-<id>/manifest.json layout)
// Be liberal in what we accept — admin may have renamed.
if (/^backup-manifest-.+\.(json|ya?ml)$/i.test(name)) return true;
if (/^manifest\.(json|ya?ml)$/i.test(name)) return true;
return false;
}
/**
* Parse a manifest file and pull out the fields the wizard wants.
* Tolerates schema drift across manifest versions (v1, v2) by
* checking multiple shapes.
*/
async function parseManifestMetadata(filePath) {
const raw = await fs.readFile(filePath, 'utf8');
let parsed;
if (filePath.toLowerCase().endsWith('.json')) {
parsed = JSON.parse(raw);
} else {
// Minimal YAML support — most admins use JSON; only do require()
// if a .yaml manifest is actually present.
const yaml = require('js-yaml');
parsed = yaml.load(raw);
}
const stats = await fs.stat(filePath);
// v2 shape: { manifest: { id, timestamp }, backup: { ... }, files: [...], database: { ... } }
// v1 shape: { backup_id, started_at, files: [...] } (older)
const manifestId =
parsed?.manifest?.id
|| parsed?.backup?.id
|| parsed?.backup_id
|| null;
const completed =
parsed?.backup?.completed_at
|| parsed?.manifest?.timestamp
|| parsed?.completed_at
|| stats.mtime;
const filesCount =
(Array.isArray(parsed?.files) ? parsed.files.length : null)
?? parsed?.backup?.total_files
?? null;
const totalSizeBytes =
parsed?.backup?.total_size_bytes
?? parsed?.total_size_bytes
?? null;
return {
type: 'local',
name: path.basename(filePath),
path: filePath,
manifestId,
size: totalSizeBytes ?? stats.size,
filesCount,
completed,
source: 'disk',
databaseIncluded: Boolean(parsed?.database?.backup_file),
// Helpful for the UI: lets it show "This backup has no DB" warning
// — exactly the surface that would have caught Ralf's original
// four files-only manifests if it had existed.
schemaVersion: parsed?.manifest?.version || parsed?.version || '1.0',
};
}
/**
* List backups for restore (POST version for frontend compatibility)
* Accepts source type in request body
*/
router.post('/list-backups', requirePermission('backup.view'), async (req, res) => {
try {
const { source } = req.body; // 'local', 's3', or undefined for all
// Use the same disk-first discovery the GET endpoint uses so that
// a fresh post-`docker compose down -v` install (empty backup_runs
// table) can still see what's on disk. The whole point of restore
// is "the DB is broken, rebuild it from disk" — a wizard that
// only queries the DB shows "No backups found" exactly when it's
// needed most. See discoverAvailableBackups for the full rationale.
const discovered = await discoverAvailableBackups();
const filtered = source
? discovered.filter((b) => b.type === source)
: discovered;
// Shape for frontend compatibility — preserves every alias the
// frontend was already reading (snake_case + camelCase), so the
// UI rendering doesn't have to change.
const backups = filtered.map((b) => ({
id: b.manifestId || null,
type: b.type,
name: b.completed
? `Backup from ${new Date(b.completed).toLocaleString()}`
: b.name,
path: b.path,
manifest_path: b.path,
manifestId: b.manifestId,
manifestPath: b.path,
size: parseInt(b.size) || 0,
total_size: parseInt(b.size) || 0,
total_size_bytes: parseInt(b.size) || 0,
filesCount: b.filesCount || 0,
files_backed_up: b.filesCount || 0,
duration: b.duration || null,
duration_seconds: b.duration || null,
created_at: b.completed,
completed_at: b.completed,
started_at: b.completed,
completedAt: b.completed,
startedAt: b.completed,
status: 'completed',
// Stage A-aware: when the source is a disk-scanned manifest we
// can tell the wizard whether the DB dump is present, so the
// UI can warn before the admin picks a files-only backup.
database_included: b.databaseIncluded,
databaseIncluded: b.databaseIncluded,
corrupt: b.corrupt || false,
// Provenance: 'disk' (manifest read from filesystem) vs 'db'
// (backup_runs row that the disk didn't surface) — useful for
// debugging which side is missing.
source: b.source,
schema_version: b.schemaVersion,
schemaVersion: b.schemaVersion,
}));
res.json({
success: true,
data: backups,
source: source || 'all'
});
} catch (error) {
logger.error('Failed to list backups for restore:', error);
res.status(500).json({
success: false,
error: 'Failed to list backups for restore'
});
}
});
/**
* Get restore settings
*/
router.get('/settings', requirePermission('backup.view'), async (req, res) => {
try {
const settings = await getRestoreSettings();
res.json({
success: true,
data: settings
});
} catch (error) {
logger.error('Failed to get restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore settings'
});
}
});
/**
* Update restore settings
*/
router.put('/settings', requirePermission('backup.restore'), [
body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
body('restore_verify_checksums').optional().isBoolean(),
body('restore_email_on_completion').optional().isBoolean(),
body('restore_retention_days').optional().isInt({ min: 1 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Update settings
for (const [key, value] of Object.entries(req.body)) {
await db('app_settings')
.where('setting_key', key)
.where('setting_type', 'restore')
.update({
setting_value: typeof value === 'boolean' ? (value ? '1' : '0') : value.toString(),
updated_at: db.fn.now()
});
}
logger.info('Restore settings updated', {
user: req.user.email,
settings: req.body
});
res.json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
logger.error('Failed to update restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to update settings'
});
}
});
/**
* Helper function to get restore settings
*/
async function getRestoreSettings() {
const settings = await db('app_settings')
.where('setting_type', 'restore')
.select('setting_key', 'setting_value');
const result = {};
settings.forEach(setting => {
// Boolean-string normalization. Historically only handled '1'/'0',
// but other code paths (boot self-heal, admin UI, direct SQL) write
// 'true' / 'false' or JSON-encoded "true" / "false". Accept all four
// shapes so `!settings.<key>` evaluates correctly downstream.
const raw = setting.setting_value;
if (raw === '1' || raw === 'true' || raw === '"true"') {
result[setting.setting_key] = true;
} else if (raw === '0' || raw === 'false' || raw === '"false"') {
result[setting.setting_key] = false;
} else {
result[setting.setting_key] = raw;
}
});
return result;
}
/**
* Helper function to get backup configuration
*/
async function getBackupConfig() {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
}
/**
* GHSA-fw4c: `source` and `manifestPath` were validated only as "not empty"
* before being handed to the privileged restore engine, which reads them,
* parses the manifest and executes the referenced SQL against the live
* database. Constrain them to the operator-configured backup locations.
*
* The allowlist is the SAME set the restore wizard discovers from
* (`backup_destination_path` + `backup_manifest_path`), so the disaster-
* recovery flow is untouched: an operator restoring from a rescued mount
* already has to point those settings at it for the backup to be listed.
* RESTORE_ALLOWED_ROOTS (colon-separated) is an escape hatch for unusual
* layouts. S3 sources are URLs, not paths, and are validated elsewhere.
*
* @returns {Promise<string|null>} an error message, or null when acceptable
*/
// `source` is usually a SOURCE TYPE, not a path: the restore wizard posts
// 'local' | 's3' | 'upload' and restoreService.restore() branches on those
// literals before deriving an actual directory (see its comment at the
// `options.source === 'local'` branch). Treating them as paths resolved
// 'local' to <cwd>/local, failed containment, and 400'd the entire normal
// restore workflow — so type tokens are excluded from the path check.
const SOURCE_TYPE_TOKENS = ['local', 's3', 'upload'];
async function checkRestorePathsAllowed({ source, manifestPath }) {
const isS3 = (v) => typeof v === 'string' && v.startsWith('s3://');
const isTypeToken = (v) => typeof v === 'string'
&& SOURCE_TYPE_TOKENS.includes(v.trim().toLowerCase());
const candidates = [source, manifestPath]
.filter((v) => v && !isS3(v) && !isTypeToken(v));
if (candidates.length === 0) return null;
const config = await getBackupConfig();
const roots = [];
if (config.backup_destination_path) roots.push(config.backup_destination_path);
if (config.backup_manifest_path) roots.push(config.backup_manifest_path);
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
if (extra.trim()) roots.push(extra.trim());
}
if (roots.length === 0) {
// Nothing configured to compare against — a restore can't be scoped, so
// don't pretend to enforce. Discovery would find nothing either.
return null;
}
const resolvedRoots = roots.map((r) => path.resolve(r));
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
const inside = resolvedRoots.some(
(root) => resolved === root || resolved.startsWith(root + path.sep)
);
if (!inside) {
logger.warn('Refusing restore path outside the configured backup roots', {
candidate, roots,
});
return 'Backup source and manifest path must be inside a configured backup location';
}
}
return null;
}
module.exports = router;
// Exposed for tests: the source/manifestPath containment rules (GHSA-fw4c) are
// worth pinning directly, especially the source-TYPE-token carve-out.
module.exports._internal = { checkRestorePathsAllowed, SOURCE_TYPE_TOKENS };