fix(backup): seal backups and require step-up auth to restore
Backups carried every password hash, encrypted MFA secret and webhook secret in plain JSON, and an import upserted arbitrary rows - including users - so anyone holding backup:import, or tricking an admin into importing a file, could overwrite the admin account with a known password. - Export (download and scheduled) is AES-256-GCM over the gzipped bundle, key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via HKDF; header is authenticated, decompression is capped. Scheduled files are written 0600 in a 0700 directory; run-now returns only the file name. - Import requires the current password (and TOTP when MFA is on). - Only a sealed file restores users, roles, grants, webhooks and audit entries; an old unsealed file restores business data only and reports what it skipped. - Audit entries are append-only on import. - Imported documents: entityType checked against the enum, MIME type allowlisted and matched against the file signature, path kept under the storage root (no more directory traversal via entityType). - Permission cache is cleared after an import. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
a25265b88a
commit
bd592adf66
15 files changed
+636
-38
No files matched your search
@@ -10,8 +10,17 @@ export function exportBackup(): Promise<BlobDownload> {
|
||||
return apiFetchBlob("/backup/export", "apicore-backup.json");
|
||||
}
|
||||
|
||||
export function importBackup(file: File): Promise<BackupImportSummary> {
|
||||
export interface BackupImportProof {
|
||||
password: string;
|
||||
mfaCode?: string;
|
||||
}
|
||||
|
||||
export function importBackup(file: File, proof: BackupImportProof): Promise<BackupImportSummary> {
|
||||
const formData = new FormData();
|
||||
formData.append("password", proof.password);
|
||||
if (proof.mfaCode) {
|
||||
formData.append("mfaCode", proof.mfaCode);
|
||||
}
|
||||
formData.append("file", file);
|
||||
return apiFetchFormData<BackupImportSummary>("/backup/import", formData);
|
||||
}
|
||||
@@ -20,8 +29,8 @@ export function getBackupSchedule(): Promise<BackupScheduleStatus> {
|
||||
return apiFetch<BackupScheduleStatus>("/backup/schedule");
|
||||
}
|
||||
|
||||
export function runBackupNow(): Promise<{ filePath: string }> {
|
||||
return apiFetch<{ filePath: string }>("/backup/schedule/run-now", {
|
||||
export function runBackupNow(): Promise<{ fileName: string }> {
|
||||
return apiFetch<{ fileName: string }>("/backup/schedule/run-now", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface BackupImportSummary {
|
||||
imported: Record<string, number>;
|
||||
/** Tables an old, unencrypted backup may not restore (users, roles, webhooks, audit log). */
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface BackupScheduleStatus {
|
||||
|
||||
Reference in new issue
Block a user