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
+40 -8
View File
@@ -3,6 +3,7 @@ const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { Transform } = require('stream');
const { createReadStream, createWriteStream } = require('fs');
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
const { db } = require('../database/db');
@@ -519,13 +520,21 @@ class RestoreService {
};
try {
// Check backup integrity
if (manifest.verification && manifest.verification.total_checksum) {
const calculatedChecksum = backupManifest.calculateManifestChecksum(manifest);
if (calculatedChecksum !== manifest.verification.total_checksum) {
validation.errors.push('Manifest checksum verification failed');
validation.isValid = false;
}
// Check backup integrity. MUST delegate to verifyManifestChecksum rather
// than recomputing here — that helper owns the legacy-serialization and
// keyed/unkeyed fallbacks (GHSA-hgp8). Recomputing with the default
// canonical+keyed settings rejected every backup written before those
// changes, i.e. every existing one.
//
// Called UNCONDITIONALLY: the old `if (…total_checksum)` guard meant an
// attacker who could rewrite the backup store simply deleted the field
// to skip verification altogether. The helper owns that case now and
// rejects it.
const checksumResult = backupManifest.verifyManifestChecksum(manifest);
checksumResult.warnings.forEach((w) => this.log('warn', w));
if (!checksumResult.valid) {
validation.errors.push(checksumResult.error || 'Manifest checksum verification failed');
validation.isValid = false;
}
// Check backup age
@@ -1512,10 +1521,33 @@ END $$;`
* Decompress gzip file
*/
async decompressFile(inputPath, outputPath) {
// Bound the EXPANDED size (GHSA-h652). gunzip happily inflates a small
// crafted .gz into an unbounded stream, filling the disk before any later
// validation runs. Cap it and fail the pipeline the moment the limit is
// crossed. The default is deliberately generous — real database dumps are
// large — and overridable for installs with genuinely bigger data.
const configured = Number(process.env.RESTORE_MAX_DECOMPRESSED_BYTES);
const maxBytes = Number.isFinite(configured) && configured > 0
? configured
: 50 * 1024 * 1024 * 1024; // 50 GB
let written = 0;
const limiter = new Transform({
transform(chunk, _enc, cb) {
written += chunk.length;
if (written > maxBytes) {
return cb(new Error(
`Decompressed size exceeds limit of ${maxBytes} bytes — refusing to continue`
));
}
cb(null, chunk);
},
});
const gunzip = zlib.createGunzip();
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gunzip, destination);
await pipeline(source, gunzip, limiter, destination);
}
/**