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:
co-authored by
Paul Nothaft
parent
acd6b453d1
commit
0d4c30884e
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user