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]>
This commit is contained in:
Paul Nothaft
2026-08-02 21:17:05 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent acd6b453d1
commit 0d4c30884e
7 changed files with 744 additions and 19 deletions
+14 -2
View File
@@ -123,8 +123,20 @@ router.post('/backup', requirePermission('backup.create'), async (req, res) => {
trackingUrl: '/api/admin/database-backup/progress'
});
// Run backup in background
databaseBackupService.backup(req.body).catch(error => {
// Forward ONLY the real backup knobs (GHSA-jw8m). Passing req.body
// straight through let the caller set `destinationPath`, which the
// service merges over its config — so a backup.create holder (the
// `admin` role, which has neither settings.edit nor backup.restore)
// could dump the whole database into the PUBLIC /uploads static mount
// and fetch it unauthenticated, hashes and encrypted SMTP creds included.
// destinationPath is not a persistable setting; the request body was its
// only source, so dropping it here costs no legitimate behaviour.
const body = req.body || {};
const options = {};
for (const key of ['compress', 'validateIntegrity', 'includeChecksums']) {
if (body[key] !== undefined) options[key] = body[key];
}
databaseBackupService.backup(options).catch(error => {
logger.error('Manual database backup failed:', error);
});
} catch (error) {
+75
View File
@@ -91,6 +91,12 @@ router.post('/validate', requirePermission('backup.restore'), [
}
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);
@@ -165,6 +171,12 @@ router.post('/start', requirePermission('backup.restore'), [
});
}
// 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) {
@@ -759,4 +771,67 @@ async function getBackupConfig() {
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 };