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
@@ -38,6 +38,11 @@ BACKUP_CRON_SCHEDULE="0 3 * * *"
|
||||
# Wie viele der neuesten Backup-Dateien behalten werden, aeltere werden nach
|
||||
# jedem Lauf geloescht (Standard: 14).
|
||||
BACKUP_RETENTION_COUNT=14
|
||||
# Backups are encrypted (AES-256-GCM). Without this key they are sealed with a
|
||||
# key derived from JWT_ACCESS_SECRET and restore only into this deployment.
|
||||
# Set the same value on another deployment to restore there. At least 32
|
||||
# characters, e.g. "openssl rand -hex 32". Keep it outside the backup storage.
|
||||
# BACKUP_ENCRYPTION_KEY=""
|
||||
|
||||
# Entra ID SSO. Ohne alle vier Werte bleibt SSO deaktiviert
|
||||
# (GET /api/v1/auth/sso/status meldet enabled: false, kein Fehler beim Start).
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MfaModule } from '../mfa/mfa.module';
|
||||
import { ReauthService } from './reauth.service';
|
||||
|
||||
@Module({
|
||||
imports: [MfaModule],
|
||||
providers: [ReauthService],
|
||||
exports: [ReauthService],
|
||||
})
|
||||
export class ReauthModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { MfaService } from '../mfa/mfa.service';
|
||||
|
||||
export interface ReauthProof {
|
||||
password?: string;
|
||||
mfaCode?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step-up check for actions that are as powerful as the account itself
|
||||
* (restoring a backup overwrites every user): a stolen access token alone
|
||||
* must not be enough, so the caller proves they know the password - and,
|
||||
* with MFA on, holds the second factor - right now.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReauthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mfaService: MfaService,
|
||||
) {}
|
||||
|
||||
async verify(userId: string, proof: ReauthProof): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
mfaEnabled: true,
|
||||
mfaSecret: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
const passwordMatches =
|
||||
Boolean(user?.isActive) &&
|
||||
typeof proof.password === 'string' &&
|
||||
(await bcrypt.compare(proof.password, user!.passwordHash));
|
||||
if (!user || !passwordMatches) {
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung Ihr aktuelles Passwort eingeben',
|
||||
);
|
||||
}
|
||||
if (user.mfaEnabled) {
|
||||
const codeMatches =
|
||||
typeof proof.mfaCode === 'string' &&
|
||||
user.mfaSecret !== null &&
|
||||
(await this.mfaService.verifyCode(
|
||||
user.mfaSecret,
|
||||
user.email,
|
||||
proof.mfaCode,
|
||||
));
|
||||
if (!codeMatches) {
|
||||
throw new UnauthorizedException(
|
||||
'Bitte zur Bestätigung den aktuellen Code aus Ihrer Authenticator-App eingeben',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { isSealedBackup, openBackup, sealBackup } from './backup-crypto';
|
||||
|
||||
describe('backup-crypto', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const bundle = {
|
||||
version: 2,
|
||||
exportedAt: '2026-10-01T09:00:00.000Z',
|
||||
data: { user: [{ id: 'u1', passwordHash: '$2a$12$secret' }] },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_ACCESS_SECRET = 'x'.repeat(40);
|
||||
delete process.env.BACKUP_ENCRYPTION_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('round-trips a bundle and hides its contents', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(isSealedBackup(sealed)).toBe(true);
|
||||
expect(JSON.stringify(sealed)).not.toContain('passwordHash');
|
||||
expect(JSON.stringify(sealed)).not.toContain('$2a$12$secret');
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('rejects a file whose ciphertext was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
const bytes = Buffer.from(sealed.ciphertext, 'base64');
|
||||
bytes[0] ^= 0xff;
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, ciphertext: bytes.toString('base64') }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file whose header was changed', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
|
||||
expect(() =>
|
||||
openBackup({ ...sealed, exportedAt: '2020-01-01T00:00:00.000Z' }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a file sealed with another key', () => {
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'y'.repeat(40);
|
||||
|
||||
expect(() => openBackup(sealed)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('uses BACKUP_ENCRYPTION_KEY independently of the JWT secret', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'k'.repeat(40);
|
||||
const sealed = sealBackup(bundle, bundle.exportedAt);
|
||||
process.env.JWT_ACCESS_SECRET = 'z'.repeat(40);
|
||||
|
||||
expect(openBackup(sealed)).toEqual(bundle);
|
||||
});
|
||||
|
||||
it('refuses a too short BACKUP_ENCRYPTION_KEY', () => {
|
||||
process.env.BACKUP_ENCRYPTION_KEY = 'short';
|
||||
expect(() => sealBackup(bundle, bundle.exportedAt)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as zlib from 'node:zlib';
|
||||
|
||||
/**
|
||||
* A backup holds everything - password hashes, encrypted MFA secrets,
|
||||
* webhook signing secrets - and an import writes straight into the users
|
||||
* table. So the file is sealed: AES-256-GCM over the gzipped bundle, which
|
||||
* keeps it unreadable outside ApiCore AND makes any change to it (or a
|
||||
* hand-made file) fail authentication before a single row is touched.
|
||||
*
|
||||
* Key: BACKUP_ENCRYPTION_KEY when set (needed to restore into another
|
||||
* deployment), otherwise derived from JWT_ACCESS_SECRET - so a deployment
|
||||
* can always restore its own backups. Either way the AES key comes from
|
||||
* HKDF with a backup-specific label, never the raw secret.
|
||||
*/
|
||||
export const SEALED_BACKUP_FORMAT = 'apicore-backup';
|
||||
export const SEALED_BACKUP_VERSION = 3;
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_BYTES = 12;
|
||||
const MIN_KEY_LENGTH = 32;
|
||||
// Decompressed size cap: a small file must not unpack into gigabytes.
|
||||
const MAX_PLAINTEXT_BYTES = 512 * 1024 * 1024;
|
||||
|
||||
export interface SealedBackup {
|
||||
format: typeof SEALED_BACKUP_FORMAT;
|
||||
version: typeof SEALED_BACKUP_VERSION;
|
||||
algorithm: 'A256GCM+gzip';
|
||||
/** Tells which key a file needs, without revealing the key. */
|
||||
keyId: string;
|
||||
exportedAt: string;
|
||||
iv: string;
|
||||
tag: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
function rootSecret(): { secret: string; source: 'backup' | 'jwt' } {
|
||||
const backupKey = process.env.BACKUP_ENCRYPTION_KEY;
|
||||
if (backupKey) {
|
||||
if (backupKey.length < MIN_KEY_LENGTH) {
|
||||
throw new Error(
|
||||
`BACKUP_ENCRYPTION_KEY must be at least ${MIN_KEY_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
return { secret: backupKey, source: 'backup' };
|
||||
}
|
||||
const jwtSecret = process.env.JWT_ACCESS_SECRET;
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_ACCESS_SECRET is not set');
|
||||
}
|
||||
return { secret: jwtSecret, source: 'jwt' };
|
||||
}
|
||||
|
||||
function deriveKey(): { key: Buffer; keyId: string } {
|
||||
const { secret, source } = rootSecret();
|
||||
const key = Buffer.from(
|
||||
crypto.hkdfSync(
|
||||
'sha256',
|
||||
secret,
|
||||
'apicore-backup',
|
||||
`apicore-backup-v${SEALED_BACKUP_VERSION}:${source}`,
|
||||
32,
|
||||
),
|
||||
);
|
||||
const keyId = crypto
|
||||
.createHash('sha256')
|
||||
.update(key)
|
||||
.digest('hex')
|
||||
.slice(0, 12);
|
||||
return { key, keyId };
|
||||
}
|
||||
|
||||
function associatedData(
|
||||
header: Pick<SealedBackup, 'format' | 'version' | 'keyId' | 'exportedAt'>,
|
||||
): Buffer {
|
||||
return Buffer.from(
|
||||
`${header.format}|${header.version}|${header.keyId}|${header.exportedAt}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function sealBackup(bundle: unknown, exportedAt: string): SealedBackup {
|
||||
const { key, keyId } = deriveKey();
|
||||
const iv = crypto.randomBytes(IV_BYTES);
|
||||
const header = {
|
||||
format: SEALED_BACKUP_FORMAT,
|
||||
version: SEALED_BACKUP_VERSION,
|
||||
keyId,
|
||||
exportedAt,
|
||||
} as const;
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
cipher.setAAD(associatedData(header));
|
||||
const compressed = zlib.gzipSync(Buffer.from(JSON.stringify(bundle)));
|
||||
const ciphertext = Buffer.concat([cipher.update(compressed), cipher.final()]);
|
||||
return {
|
||||
...header,
|
||||
algorithm: 'A256GCM+gzip',
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
ciphertext: ciphertext.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function isSealedBackup(value: unknown): value is SealedBackup {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Record<string, unknown>;
|
||||
return (
|
||||
candidate.format === SEALED_BACKUP_FORMAT &&
|
||||
typeof candidate.ciphertext === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/** The bundle inside a sealed backup; throws if the file was not made with this key or was changed. */
|
||||
export function openBackup(sealed: SealedBackup): unknown {
|
||||
if (sealed.version !== SEALED_BACKUP_VERSION) {
|
||||
throw new BadRequestException(
|
||||
`Backup-Version ${String(sealed.version)} wird nicht unterstützt`,
|
||||
);
|
||||
}
|
||||
const { key, keyId } = deriveKey();
|
||||
if (sealed.keyId !== keyId) {
|
||||
throw new BadRequestException(
|
||||
'Backup wurde mit einem anderen Schlüssel erstellt (BACKUP_ENCRYPTION_KEY prüfen)',
|
||||
);
|
||||
}
|
||||
let compressed: Buffer;
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
ALGORITHM,
|
||||
key,
|
||||
Buffer.from(sealed.iv, 'base64'),
|
||||
);
|
||||
decipher.setAAD(associatedData(sealed));
|
||||
decipher.setAuthTag(Buffer.from(sealed.tag, 'base64'));
|
||||
compressed = Buffer.concat([
|
||||
decipher.update(Buffer.from(sealed.ciphertext, 'base64')),
|
||||
decipher.final(),
|
||||
]);
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
'Backup-Datei ist beschädigt oder wurde verändert',
|
||||
);
|
||||
}
|
||||
let json: string;
|
||||
try {
|
||||
json = zlib
|
||||
.gunzipSync(compressed, { maxOutputLength: MAX_PLAINTEXT_BYTES })
|
||||
.toString('utf-8');
|
||||
} catch {
|
||||
throw new BadRequestException('Backup-Datei ist zu groß oder beschädigt');
|
||||
}
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
|
||||
// Manual trigger (e.g. "Jetzt sichern" button) - always runs, no
|
||||
// duplicate-tick guard, since a human explicitly asked for it.
|
||||
async runNow(): Promise<{ filePath: string }> {
|
||||
async runNow(): Promise<{ fileName: string }> {
|
||||
const settings = await this.effectiveSettings();
|
||||
if (!settings.path) {
|
||||
throw new Error(
|
||||
@@ -153,7 +153,8 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
const filePath = await this.writeBackup(settings.path);
|
||||
await this.pruneOldBackups(settings.path, settings.retentionCount);
|
||||
return { filePath };
|
||||
// Only the name: the server's directory layout stays on the server.
|
||||
return { fileName: path.basename(filePath) };
|
||||
}
|
||||
|
||||
private async runScheduled(directory: string): Promise<void> {
|
||||
@@ -176,14 +177,15 @@ export class BackupSchedulerService implements OnModuleInit {
|
||||
}
|
||||
|
||||
private async writeBackup(directory: string): Promise<string> {
|
||||
const bundle = await this.backupService.exportAll();
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const timestamp = bundle.exportedAt.replace(/[:.]/g, '-');
|
||||
const sealed = await this.backupService.exportSealed();
|
||||
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const timestamp = sealed.exportedAt.replace(/[:.]/g, '-');
|
||||
const filePath = path.join(
|
||||
directory,
|
||||
`${BACKUP_FILE_PREFIX}${timestamp}${BACKUP_FILE_SUFFIX}`,
|
||||
);
|
||||
await fs.writeFile(filePath, JSON.stringify(bundle, null, 2));
|
||||
// Owner-only: the file holds every account's credentials (encrypted).
|
||||
await fs.writeFile(filePath, JSON.stringify(sealed), { mode: 0o600 });
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ReauthModule } from '../auth/reauth/reauth.module';
|
||||
import { BackupController } from './backup.controller';
|
||||
import { BackupService } from './backup.service';
|
||||
import { BackupSchedulerService } from './backup-scheduler.service';
|
||||
import { BackupSettingsService } from './backup-settings.service';
|
||||
|
||||
@Module({
|
||||
imports: [ReauthModule],
|
||||
controllers: [BackupController],
|
||||
providers: [BackupService, BackupSchedulerService, BackupSettingsService],
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { BackupService } from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
@@ -69,6 +70,8 @@ describe('BackupService', () => {
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { invalidateAll: jest.Mock };
|
||||
const TRUSTED = { trusted: true };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -92,6 +95,9 @@ describe('BackupService', () => {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
||||
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
prisma = {
|
||||
user: {
|
||||
@@ -114,9 +120,11 @@ describe('BackupService', () => {
|
||||
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
permissionResolver = { invalidateAll: jest.fn() };
|
||||
service = new BackupService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -198,7 +206,7 @@ describe('BackupService', () => {
|
||||
describe('importAll', () => {
|
||||
it('rejects a bundle with no data object', async () => {
|
||||
await expect(
|
||||
service.importAll({ version: 2 } as never, 'admin-1'),
|
||||
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -211,6 +219,7 @@ describe('BackupService', () => {
|
||||
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.role.upsert.mock.calls as [
|
||||
@@ -233,6 +242,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.user.upsert.mock.calls as [
|
||||
@@ -252,6 +262,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -275,6 +286,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
||||
@@ -293,6 +305,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||
@@ -326,6 +339,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -352,6 +366,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -372,6 +387,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||
@@ -396,6 +412,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||
@@ -422,14 +439,14 @@ describe('BackupService', () => {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: 'known-user',
|
||||
contentBase64: Buffer.from('hello').toString('base64'),
|
||||
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: null,
|
||||
contentBase64: null,
|
||||
@@ -438,6 +455,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
@@ -457,6 +475,7 @@ describe('BackupService', () => {
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -464,9 +483,111 @@ describe('BackupService', () => {
|
||||
entityType: 'Backup',
|
||||
action: 'CREATE',
|
||||
userId: 'admin-1',
|
||||
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
afterData: expect.objectContaining({
|
||||
imported: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose entityType would leave the storage root', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-evil',
|
||||
entityType: '../../app/dist',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose bytes do not match its declared type', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-html',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('<script>').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never rewrites audit entries that already exist', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
const [[call]] = tx.auditLog.upsert.mock.calls as [
|
||||
[{ update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.update).toEqual({});
|
||||
});
|
||||
|
||||
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
user: [
|
||||
{
|
||||
id: 'admin-victim',
|
||||
email: '[email protected]',
|
||||
passwordHash: 'attacker-hash',
|
||||
roleKey: 'ADMIN',
|
||||
},
|
||||
],
|
||||
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
|
||||
auditLog: [{ id: 'audit-1' }],
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.role.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.user.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
/** Step-up proof sent with a backup upload (multipart fields). */
|
||||
export class BackupImportProofDto {
|
||||
@ApiPropertyOptional({ description: 'Aktuelles Passwort' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Aktueller TOTP-Code, falls MFA aktiv' })
|
||||
@IsOptional()
|
||||
@Matches(/^\d{6}$/)
|
||||
mfaCode?: string;
|
||||
}
|
||||
Reference in new issue
Block a user