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
@@ -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 & 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"
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user