Files
opencrm/backend/src/controllers/backup.controller.ts
T
duffyduck bf7afdd9a6 Pentest KRITISCH: Backup-Restore braucht Confirm-Body
POST /api/settings/backup/:name/restore startete bei leerem Body
sofort den destruktiven Restore. Im Unterschied zu /factory-reset
fehlte der Magic-String-Confirm-Check, sodass ein versehentlicher
Re-Fire (Doppelklick, Browser-Tab-Replay, eingeloggter Admin auf
bösartiger Drittseite) die komplette DB stillschweigend
überschreiben konnte.

Fix: gleicher Defensive-Pattern wie factoryReset – Body muss
{ "confirm": "RESTORE-BESTAETIGT" } enthalten, sonst 400. Der
Magic-String ist absichtlich ein einzigartiges Token (kein Boolean),
damit kein Auto-JSON-Tooling/Replay aus Versehen triggern kann.

Frontend-API-Client setzt das Token im Body automatisch – der
existierende Bestätigungs-Dialog im UI bleibt UX-mäßig unverändert.

Live-verifiziert:
- leerer Body → 400
- { confirm: "ja" } → 400
- { confirm: "RESTORE-BESTAETIGT" } → 200, Restore läuft

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 01:05:00 +02:00

445 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Request, Response } from 'express';
import * as backupService from '../services/backup.service.js';
import prisma from '../lib/prisma.js';
/**
* Validiert Backup-Namen: nur Zeichen die auch der Backup-Generator erstellen darf
* (ISO-Zeitstempel mit Buchstaben, Zahlen, Bindestrich, optional -N Suffix).
* Blockt Path-Traversal-Versuche wie "../../etc/passwd".
*/
function isValidBackupName(name: string): boolean {
return /^[A-Za-z0-9_-]+$/.test(name) && !name.includes('..');
}
import { logChange } from '../services/audit.service.js';
// Fängt console.log/info/warn/error für die Laufzeit einer Operation in
// einen Puffer mit ab (zusätzlich landet alles weiterhin in stdout/stderr).
// Wird in createBackup/restoreBackup verwendet, um den vollständigen
// Verlauf in `BackupLog.fullLog` zu persistieren. Da die Backup-Operationen
// in der Praxis nicht parallel laufen (Single-User-Admin-UI), reicht die
// process-globale Patch-Variante.
function startLogCapture(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
function fmt(args: unknown[]): string {
return args
.map((a) => {
if (a instanceof Error) return a.stack || a.message;
if (typeof a === 'object') {
try {
return JSON.stringify(a);
} catch {
return String(a);
}
}
return String(a);
})
.join(' ');
}
console.log = (...args: unknown[]) => { lines.push(fmt(args)); orig.log(...args); };
console.info = (...args: unknown[]) => { lines.push(fmt(args)); orig.info(...args); };
console.warn = (...args: unknown[]) => { lines.push(`[WARN] ${fmt(args)}`); orig.warn(...args); };
console.error = (...args: unknown[]) => { lines.push(`[ERROR] ${fmt(args)}`); orig.error(...args); };
return {
lines,
restore: () => {
console.log = orig.log;
console.info = orig.info;
console.warn = orig.warn;
console.error = orig.error;
},
};
}
async function recordBackupLog(opts: {
req: Request;
operation: 'CREATE' | 'RESTORE';
backupName: string | null;
success: boolean;
durationMs: number;
summary: string;
fullLog: string;
}) {
try {
const user = (opts.req as any).user;
await prisma.backupLog.create({
data: {
operation: opts.operation,
backupName: opts.backupName,
success: opts.success,
durationMs: opts.durationMs,
summary: opts.summary.slice(0, 2000),
// LongText: bis ~4 GB, aber wir cappen bei 1 MB damit nichts entgleist
fullLog: opts.fullLog.slice(0, 1_000_000),
userId: user?.userId ?? null,
userEmail: user?.email ?? null,
ipAddress:
(opts.req as any).socket?.remoteAddress ||
(opts.req.headers?.['x-forwarded-for'] as string) ||
null,
},
});
} catch (err) {
console.error('[BackupLog] Konnte Log nicht persistieren:', err);
}
}
/**
* Liste aller Backups abrufen
* GET /api/settings/backups
*/
export async function listBackups(req: Request, res: Response) {
try {
const backups = await backupService.listBackups();
res.json({ data: backups });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden der Backups', details: error.message });
}
}
/**
* Neues Backup erstellen
* POST /api/settings/backup
*/
export async function createBackup(req: Request, res: Response) {
const start = Date.now();
const capture = startLogCapture();
try {
const result = await backupService.createBackup();
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `Backup ${result.backupName} erstellt (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'CREATE', backupName: result.backupName ?? null,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'CREATE', resourceType: 'Backup',
label: `Backup ${result.backupName} erstellt`,
});
res.json({ data: { backupName: result.backupName }, message: 'Backup erfolgreich erstellt' });
} else {
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Backup fehlgeschlagen: ${result.error || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({ error: 'Backup fehlgeschlagen', details: result.error });
}
} catch (error: any) {
const durationMs = Date.now() - start;
capture.restore();
await recordBackupLog({
req, operation: 'CREATE', backupName: null,
success: false, durationMs,
summary: `Fehler: ${error?.message || 'unbekannt'}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({ error: 'Fehler beim Erstellen des Backups', details: error.message });
}
}
/**
* Backup wiederherstellen
* POST /api/settings/backup/:name/restore
*/
// Macht eine Fehlermeldung admin-lesbar OHNE den globalen ORM-Leak-Filter
// auszulösen: Stack-Frames raus, "TypeError: …" → "Code-Fehler: …",
// "Cannot read properties of undefined" → "Interner Code-Fehler".
// Vollständiger Stack landet immer im Server-Log (siehe `console.error`).
function makeRestoreErrorReadable(raw: unknown): string {
if (!raw) return 'Unbekannter Fehler';
let s = typeof raw === 'string' ? raw : (raw as any)?.message || String(raw);
// Stack-Frames " at …(…:123:45)" abschneiden
s = s.split('\n').filter((line: string) => !/^\s*at\s+/.test(line)).join('\n').trim();
// Bekannte JS-Runtime-Marker rephrasen, damit der orm-leak-guard nicht
// alles auf "Operation fehlgeschlagen" maskiert.
s = s
.replace(/^TypeError:?\s*/i, 'Code-Fehler: ')
.replace(/^ReferenceError:?\s*/i, 'Code-Fehler: ')
.replace(/^SyntaxError:?\s*/i, 'Code-Fehler: ')
.replace(/^RangeError:?\s*/i, 'Code-Fehler: ')
.replace(/Cannot read propert(?:y|ies) of (undefined|null) \(reading '([^']+)'\)/i, 'Wert fehlt: $2')
.replace(/is not a function/i, '(ungültiger Funktionsaufruf)')
.replace(/is not defined$/i, '(Wert nicht definiert)')
.replace(/Invalid `prisma\.[^`]+`/i, 'DB-Fehler');
return s.slice(0, 500); // Längenlimit für UI
}
export async function restoreBackup(req: Request, res: Response) {
const start = Date.now();
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
// Pflicht-Confirm im Body, gleiche Defensive wie factoryReset.
// Pentest 2026-05-19 (KRITISCH): leerer POST-Body löste vorher
// sofort den destruktiven Restore aus ein versehentlicher
// Re-Fire (Browser-Tab, CSRF auf eingeloggten Admin, doppelter
// Klick) konnte die DB ungewollt überschreiben. Der String ist
// bewusst ein unique Magic-Value, kein Boolean.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'RESTORE-BESTAETIGT') {
return res.status(400).json({
error: 'Bestätigung fehlt. Body muss { "confirm": "RESTORE-BESTAETIGT" } enthalten.',
});
}
const capture = startLogCapture();
try {
const result = await backupService.restoreBackup(name);
const durationMs = Date.now() - start;
if (result.success) {
capture.restore();
const summary = `${result.restoredRecords} Datensätze, ${result.restoredFiles || 0} Dateien (${(durationMs / 1000).toFixed(1)}s)`;
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: true, durationMs, summary,
fullLog: capture.lines.join('\n') || summary,
});
await logChange({
req, action: 'UPDATE', resourceType: 'Backup',
label: `Backup ${name} wiederhergestellt`,
});
res.json({
data: {
restoredRecords: result.restoredRecords,
restoredFiles: result.restoredFiles,
},
message: `${result.restoredRecords} Datensätze und ${result.restoredFiles || 0} Dateien wiederhergestellt`,
});
} else {
console.error(`[restore] Backup ${name} fehlgeschlagen:`, result.error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Fehlgeschlagen: ${makeRestoreErrorReadable(result.error)}`,
fullLog: capture.lines.join('\n') + '\n[Fehler] ' + (result.error || ''),
});
res.status(500).json({
error: 'Wiederherstellung fehlgeschlagen',
details: makeRestoreErrorReadable(result.error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
} catch (error: any) {
const durationMs = Date.now() - start;
console.error(`[restore] Exception bei Backup ${name}:`, error?.stack || error);
capture.restore();
await recordBackupLog({
req, operation: 'RESTORE', backupName: name,
success: false, durationMs,
summary: `Exception: ${makeRestoreErrorReadable(error)}`,
fullLog: capture.lines.join('\n') + '\n[Exception] ' + (error?.stack || error?.message || error),
});
res.status(500).json({
error: 'Fehler bei der Wiederherstellung',
details: makeRestoreErrorReadable(error),
hint: 'Vollständiger Verlauf in den Backup-Logs unterhalb der Backup-Liste.',
});
}
}
/**
* Backup löschen
* DELETE /api/settings/backup/:name
*/
export async function deleteBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.deleteBackup(name);
if (result.success) {
await logChange({
req, action: 'DELETE', resourceType: 'Backup',
label: `Backup ${name} gelöscht`,
});
res.json({ message: 'Backup gelöscht' });
} else {
res.status(500).json({ error: 'Löschen fehlgeschlagen', details: result.error });
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Löschen des Backups', details: error.message });
}
}
/**
* Backup als ZIP herunterladen
* GET /api/settings/backup/:name/download
*/
export async function downloadBackup(req: Request, res: Response) {
try {
const { name } = req.params;
if (!name || !isValidBackupName(name)) {
return res.status(400).json({ error: 'Ungültiger Backup-Name' });
}
const result = await backupService.createBackupZip(name);
if ('error' in result) {
return res.status(404).json({ error: result.error });
}
// Response-Header setzen
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`);
// Archiver zum Response pipen
result.stream.pipe(res);
// Archiver finalisieren (startet das Schreiben)
result.stream.finalize();
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Download', details: error.message });
}
}
/**
* Backup-ZIP hochladen
* POST /api/settings/backup/upload
*/
export async function uploadBackup(req: Request, res: Response) {
try {
if (!req.file) {
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
}
// Prüfen ob es eine ZIP-Datei ist
if (!req.file.originalname.endsWith('.zip')) {
return res.status(400).json({ error: 'Nur ZIP-Dateien sind erlaubt' });
}
const result = await backupService.uploadBackupZip(req.file.buffer);
if (result.success) {
res.json({
data: { backupName: result.backupName },
message: 'Backup erfolgreich hochgeladen',
});
} else {
res.status(400).json({ error: 'Upload fehlgeschlagen', details: result.error });
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Upload', details: error.message });
}
}
/**
* Werkseinstellungen - Alle Daten löschen
* POST /api/settings/factory-reset
*/
export async function factoryReset(req: Request, res: Response) {
try {
// Bestätigung erforderlich: client MUSS explizit
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
// POST plätten (Pentest Runde 11 (2026-05-18) C2 KRITISCH:
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
// Replay-Angriff aus Versehen triggern kann.
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
res.status(400).json({
success: false,
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
});
return;
}
const result = await backupService.factoryReset();
if (result.success) {
await logChange({
req, action: 'DELETE', resourceType: 'System',
label: `Werkseinstellungen wiederhergestellt`,
});
res.json({
message: 'Werkseinstellungen wiederhergestellt. Bitte melden Sie sich mit admin@admin.com / admin an.',
});
} else {
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
}
} catch (error: any) {
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
console.error('factoryReset error:', error);
}
}
/**
* Liste der Backup-Logs (CREATE oder RESTORE)
* GET /api/settings/backup-logs?operation=CREATE|RESTORE&limit=50
* Liefert die Übersichtsdaten OHNE den großen fullLog.
*/
export async function listBackupLogs(req: Request, res: Response) {
try {
const op = String(req.query.operation || '').toUpperCase();
const limit = Math.min(Math.max(parseInt(String(req.query.limit || '50'), 10) || 50, 1), 200);
const where: any = {};
if (op === 'CREATE' || op === 'RESTORE') {
where.operation = op;
}
const logs = await prisma.backupLog.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
select: {
id: true,
operation: true,
backupName: true,
success: true,
durationMs: true,
summary: true,
userEmail: true,
ipAddress: true,
createdAt: true,
},
});
res.json({ data: logs });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden der Logs', details: error.message });
}
}
/**
* Detail eines Backup-Logs inkl. fullLog
* GET /api/settings/backup-logs/:id
*/
export async function getBackupLogDetail(req: Request, res: Response) {
try {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
return res.status(400).json({ error: 'Ungültige ID' });
}
const log = await prisma.backupLog.findUnique({ where: { id } });
if (!log) {
return res.status(404).json({ error: 'Log-Eintrag nicht gefunden' });
}
res.json({ data: log });
} catch (error: any) {
res.status(500).json({ error: 'Fehler beim Laden des Log-Details', details: error.message });
}
}