bf7afdd9a6
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>
445 lines
15 KiB
TypeScript
445 lines
15 KiB
TypeScript
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 });
|
||
}
|
||
}
|