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

@@ -13,6 +13,7 @@ import {
updateBackupSettings,
} from "@/lib/backup/api";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth/auth-context";
import { BackupScheduleStatus, BackupSettings } from "@/lib/backup/types";
import { importCsv } from "@/lib/import/api";
import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types";
@@ -48,7 +49,12 @@ function downloadBlob(blob: Blob, filename: string): void {
}
function BackupSection() {
const { user } = useAuth();
const [file, setFile] = useState<File | null>(null);
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const needsMfa = Boolean(user?.mfaEnabled);
const proofComplete = password.length > 0 && (!needsMfa || /^\d{6}$/.test(mfaCode));
const exportMutation = useMutation({
mutationFn: exportBackup,
@@ -64,14 +70,26 @@ function BackupSection() {
if (!file) {
throw new Error("Keine Datei ausgewählt");
}
return importBackup(file);
return importBackup(file, { password, mfaCode: needsMfa ? mfaCode : undefined });
},
onSuccess: (summary) => {
const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0);
toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`);
if (summary.skipped.length > 0) {
toast.warning(
"Altes, unverschlüsseltes Backup: Benutzer, Rollen, Webhooks und Audit-Log wurden nicht übernommen.",
);
}
setFile(null);
setPassword("");
setMfaCode("");
},
onError: () => toast.error("Backup konnte nicht eingespielt werden"),
onError: (error) =>
toast.error(
error instanceof Error && error.message
? error.message
: "Backup konnte nicht eingespielt werden",
),
});
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
@@ -87,7 +105,9 @@ function BackupSection() {
<p className="text-muted-foreground">
Sichert wirklich alle Daten - Bienenstände, Völker, Ernten und mehr, dazu Benutzer,
Rollen &amp; Rechte, Audit-Log und hochgeladene Dokumente (inkl. Dateiinhalt) - in
einer JSON-Datei. Für Backups und zur Übertragung auf ein anderes Deployment.
einer verschlüsselten Datei. Einspielen lässt sie sich nur in ApiCore mit demselben
Backup-Schlüssel; für ein anderes Deployment dort denselben{" "}
<code className="font-mono text-xs">BACKUP_ENCRYPTION_KEY</code> setzen.
</p>
<Button
type="button"
@@ -107,9 +127,35 @@ function BackupSection() {
onChange={handleFileChange}
className="text-sm file:mr-3 file:rounded-lg file:border-0 file:bg-muted file:px-3 file:py-2 file:text-sm file:font-medium"
/>
<p className="text-muted-foreground">
Einspielen überschreibt Konten und Daten. Zur Bestätigung Ihr Passwort
{needsMfa ? " und den aktuellen Code aus der Authenticator-App" : ""} eingeben.
</p>
<Label htmlFor="backup-password">Ihr Passwort</Label>
<Input
id="backup-password"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
{needsMfa && (
<>
<Label htmlFor="backup-mfa">Code aus der Authenticator-App</Label>
<Input
id="backup-mfa"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={mfaCode}
onChange={(event) => setMfaCode(event.target.value.replace(/\D/g, ""))}
/>
</>
)}
<ConfirmDialog
triggerLabel="Backup importieren"
triggerDisabled={!file || importMutation.isPending}
triggerDisabled={!file || !proofComplete || importMutation.isPending}
title="Backup einspielen?"
description="Datensätze aus der Datei werden angelegt oder mit bestehenden gleicher ID überschrieben. Bereits vorhandene Daten, die in der Datei nicht enthalten sind, bleiben unverändert. Das kann bei einer großen Datei einen Moment dauern."
confirmLabel="Einspielen"
+12 -3
View File
@@ -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",
});
}
+2
View File
@@ -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 {