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
@@ -16,7 +16,9 @@ import { RequirePermission } from '../auth/decorators/require-permission.decorat
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { BackupSettings } from '../../generated/prisma';
|
||||
import { BackupBundle, BackupService } from './backup.service';
|
||||
import { BackupService } from './backup.service';
|
||||
import { ReauthService } from '../auth/reauth/reauth.service';
|
||||
import { BackupImportProofDto } from './dto/backup-import-proof.dto';
|
||||
import {
|
||||
BackupScheduleStatus,
|
||||
BackupSchedulerService,
|
||||
@@ -38,6 +40,7 @@ export class BackupController {
|
||||
private readonly backupService: BackupService,
|
||||
private readonly backupSchedulerService: BackupSchedulerService,
|
||||
private readonly backupSettingsService: BackupSettingsService,
|
||||
private readonly reauth: ReauthService,
|
||||
) {}
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@@ -48,7 +51,7 @@ export class BackupController {
|
||||
|
||||
@RequirePermission('backup:export')
|
||||
@Post('schedule/run-now')
|
||||
runScheduleNow(): Promise<{ filePath: string }> {
|
||||
runScheduleNow(): Promise<{ fileName: string }> {
|
||||
return this.backupSchedulerService.runNow();
|
||||
}
|
||||
|
||||
@@ -72,11 +75,12 @@ export class BackupController {
|
||||
@RequirePermission('backup:export')
|
||||
@Get('export')
|
||||
async export(@Res() res: Response): Promise<void> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
const filename = `apicore-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(JSON.stringify(bundle, null, 2));
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(sealed));
|
||||
}
|
||||
|
||||
@RequirePermission('backup:import')
|
||||
@@ -99,17 +103,20 @@ export class BackupController {
|
||||
)
|
||||
async import(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() proof: BackupImportProofDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
// Restoring overwrites every account: prove it is really you, now.
|
||||
await this.reauth.verify(actor.id, proof);
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
let bundle: BackupBundle;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
bundle = JSON.parse(file.buffer.toString('utf-8')) as BackupBundle;
|
||||
parsed = JSON.parse(file.buffer.toString('utf-8'));
|
||||
} catch {
|
||||
throw new BadRequestException('Datei ist kein gültiges JSON');
|
||||
}
|
||||
return this.backupService.importAll(bundle, actor.id);
|
||||
return this.backupService.importFile(parsed, actor.id);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user