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:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:12:43 +02:00
1 parent a25265b88a
commit bd592adf66
15 files changed
+636 -38

No files matched your search

+107 -13
View File
@@ -4,7 +4,19 @@ import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { ALLOWED_DOCUMENT_MIME_TYPES } from '../documents/document-mime-types';
import {
ALLOWED_DOCUMENT_MIME_TYPES,
isAllowedDocumentMimeType,
} from '../documents/document-mime-types';
import { matchesFileSignature } from '../common/file-signature';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { DocumentEntityType } from '../../generated/prisma';
import {
SealedBackup,
isSealedBackup,
openBackup,
sealBackup,
} from './backup-crypto';
const BACKUP_VERSION = 2;
const IMPORT_TRANSACTION_TIMEOUT_MS = 10 * 60 * 1000;
@@ -17,8 +29,28 @@ export interface BackupBundle {
export interface BackupImportSummary {
imported: Record<string, number>;
/**
* Tables left out because the file was an old, unsealed (v2) backup: its
* contents cannot be proven to come from ApiCore, so nothing that grants
* access (users, roles, webhooks) or rewrites history (audit log) is
* taken from it.
*/
skipped: string[];
}
// What an unsealed file may never write - see BackupImportSummary.skipped.
const TRUSTED_ONLY_TABLES = [
'role',
'user',
'rolePermission',
'webhookSubscription',
'auditLog',
] as const;
const DOCUMENT_ENTITY_TYPES = new Set<string>(
Object.values(DocumentEntityType),
);
// Excluded on purpose:
// - Permission: code-derived, re-synced from the catalog on every boot -
// including it would just fight that sync. RolePermission references
@@ -55,8 +87,33 @@ export class BackupService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService,
) {}
/** The export as it leaves the server: encrypted and tamper-evident. */
async exportSealed(): Promise<SealedBackup> {
const bundle = await this.exportAll();
return sealBackup(bundle, bundle.exportedAt);
}
/**
* Restores an uploaded file. A sealed backup is restored completely; an
* old unsealed one only with its business data (see TRUSTED_ONLY_TABLES).
*/
async importFile(
parsed: unknown,
actingUserId: string,
): Promise<BackupImportSummary> {
if (isSealedBackup(parsed)) {
return this.importAll(openBackup(parsed) as BackupBundle, actingUserId, {
trusted: true,
});
}
return this.importAll(parsed as BackupBundle, actingUserId, {
trusted: false,
});
}
async exportAll(): Promise<BackupBundle> {
const data: Record<string, unknown[]> = {};
@@ -145,6 +202,7 @@ export class BackupService {
async importAll(
bundle: BackupBundle,
actingUserId: string,
{ trusted }: { trusted: boolean },
): Promise<BackupImportSummary> {
if (
typeof bundle !== 'object' ||
@@ -155,7 +213,18 @@ export class BackupService {
throw new BadRequestException('Invalid backup file');
}
const isTrustedOnly = (key: string): boolean =>
(TRUSTED_ONLY_TABLES as readonly string[]).includes(key);
const skipped = trusted
? []
: TRUSTED_ONLY_TABLES.filter((key) => {
const value = bundle.data[key];
return Array.isArray(value) && value.length > 0;
});
const rowsOf = (key: string): Row[] => {
if (!trusted && isTrustedOnly(key)) {
return [];
}
const value = bundle.data[key];
return Array.isArray(value) ? (value as Row[]) : [];
};
@@ -165,6 +234,13 @@ export class BackupService {
await this.prisma.$transaction(
async (tx) => {
const roleKeyToId = new Map<string, string>();
// Roles already in this database resolve too - an unsealed file
// brings none of its own.
for (const role of await tx.role.findMany({
select: { id: true, key: true },
})) {
roleKeyToId.set(role.key, role.id);
}
for (const row of rowsOf('role')) {
const withoutId: Row = { ...row };
delete withoutId.id;
@@ -693,13 +769,30 @@ export class BackupService {
if (!contentBase64) {
continue;
}
const entityType = asString(row, 'entityType');
const mimeType = asString(row, 'mimeType');
if (
!entityType ||
!DOCUMENT_ENTITY_TYPES.has(entityType) ||
!mimeType ||
!isAllowedDocumentMimeType(mimeType)
) {
throw new BadRequestException(
`Dokument ${String(row.id)} hat einen unzulässigen Typ`,
);
}
const buffer = Buffer.from(contentBase64, 'base64');
const extension =
ALLOWED_DOCUMENT_MIME_TYPES[row.mimeType as string] ?? '';
const directory = path.join(
documentsStorageRoot(),
(row.entityType as string).toLowerCase(),
);
if (!matchesFileSignature(buffer, mimeType)) {
throw new BadRequestException(
`Dokument ${String(row.id)}: Inhalt passt nicht zum Dateityp`,
);
}
const extension = ALLOWED_DOCUMENT_MIME_TYPES[mimeType];
const storageRoot = path.resolve(documentsStorageRoot());
const directory = path.join(storageRoot, entityType.toLowerCase());
if (!directory.startsWith(storageRoot + path.sep)) {
throw new BadRequestException('Unzulässiger Speicherpfad');
}
await fs.mkdir(directory, { recursive: true });
const storagePath = path.join(
directory,
@@ -739,6 +832,7 @@ export class BackupService {
}
imported.notification = rowsOf('notification').length;
// Append-only: entries already here are never rewritten by a file.
for (const row of rowsOf('auditLog')) {
await tx.auditLog.upsert({
where: { id: row.id as string },
@@ -746,10 +840,7 @@ export class BackupService {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {
...row,
userId: resolveOptionalUserId(asString(row, 'userId')),
} as never,
update: {},
});
}
imported.auditLog = rowsOf('auditLog').length;
@@ -757,14 +848,17 @@ export class BackupService {
{ timeout: IMPORT_TRANSACTION_TIMEOUT_MS },
);
// Users, roles and grants may have changed underneath every cache.
this.permissionResolver.invalidateAll();
await this.auditLog.record({
entityType: 'Backup',
entityId: 'import',
action: 'CREATE',
userId: actingUserId,
afterData: imported,
afterData: { imported, skipped, sealed: trusted },
});
return { imported };
return { imported, skipped };
}
}