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

@@ -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',
);
}
}
}
}