diff --git a/backend/src/controllers/appSetting.controller.ts b/backend/src/controllers/appSetting.controller.ts index 0e88497c..12bc8b4a 100644 --- a/backend/src/controllers/appSetting.controller.ts +++ b/backend/src/controllers/appSetting.controller.ts @@ -56,7 +56,17 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise = {}; + const sanitizedEntries: Array<{ key: string; oldValue: string; newValue: string }> = []; for (const [key, value] of Object.entries(settings)) { const before = await prisma.appSetting.findUnique({ where: { key } }); const oldValue = before?.value ?? '-'; - const newValue = appSettingService.sanitizeSettingValue(key, String(value)); + const stripped = appSettingService.sanitizeSettingValue(key, String(value)); + const validation = appSettingService.validateSettingValue(key, stripped); + if (!validation.ok) { + res.status(400).json({ success: false, error: `${key}: ${validation.error}` } as ApiResponse); + return; + } + sanitizedEntries.push({ key, oldValue, newValue: validation.value }); + } + for (const { key, oldValue, newValue } of sanitizedEntries) { if (oldValue !== newValue) { changes[key] = { von: oldValue, nach: newValue }; } diff --git a/backend/src/services/appSetting.service.ts b/backend/src/services/appSetting.service.ts index f1502fcb..754bc44f 100644 --- a/backend/src/services/appSetting.service.ts +++ b/backend/src/services/appSetting.service.ts @@ -1,5 +1,6 @@ import prisma from '../lib/prisma.js'; import { stripHtml } from '../utils/sanitize.js'; +import { isBlockedSsrfHost } from '../utils/ssrfGuard.js'; // Default settings const DEFAULT_SETTINGS: Record = { @@ -64,6 +65,85 @@ export function sanitizeSettingValue(key: string, value: string): string { return typeof stripped === 'string' ? stripped : String(stripped); } +/** + * Schema-spezifische Wert-Validierung VOR dem Speichern. Wird vom + * Controller aufgerufen; liefert entweder { ok: true, value: } + * oder { ok: false, error: } für 400. + * + * Hintergrund Pentest 2026-05-28 LOW 34.5: Schema-Whitelist und + * Slash-Trimming standen NUR im Frontend, der API-Endpoint nahm + * relative URLs (`/evil/path`), `javascript:`-Schemata und Adressen + * auf private Hosts (`http://192.168.1.1`) ungeprüft entgegen. Bei + * Cloud-Deployment war das ein SSRF-/Open-Redirect-Vektor in der + * an Kunden verschickten Mail. + */ +export function validateSettingValue(key: string, rawValue: string): { ok: true; value: string } | { ok: false; error: string } { + // Schwellenwerte: müssen positive ganze Zahlen sein, sonst läuft das + // Cockpit in NaN-Vergleichen. Bestehende Validierung war nicht + // konsequent. + const intKeys = new Set(['deadlineCriticalDays', 'deadlineWarningDays', 'deadlineOkDays', 'documentExpiryCriticalDays', 'documentExpiryWarningDays']); + if (intKeys.has(key)) { + const trimmed = rawValue.trim(); + if (!/^\d+$/.test(trimmed)) { + return { ok: false, error: `${key} muss eine positive ganze Zahl sein.` }; + } + return { ok: true, value: trimmed }; + } + + // Bool-Settings + if (key === 'customerSupportTicketsEnabled' || key === 'monitoringDigestEnabled') { + const trimmed = rawValue.trim().toLowerCase(); + if (trimmed !== 'true' && trimmed !== 'false') { + return { ok: false, error: `${key} muss 'true' oder 'false' sein.` }; + } + return { ok: true, value: trimmed }; + } + + // Email-Settings (Format-Check analog zu Customer/User – verhindert + // Header-Injection in System-Mails) + if (key === 'monitoringAlertEmail') { + const trimmed = rawValue.trim(); + if (trimmed === '') return { ok: true, value: '' }; + // RFC-5322-light, gleiches Pattern wie isValidEmail in utils/sanitize + if (/[\r\n\t\0\v\f]/.test(trimmed) || trimmed.length > 254) { + return { ok: false, error: 'Ungültige E-Mail-Adresse.' }; + } + if (!/^[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9.\-]{1,253}\.[A-Za-z]{2,}$/.test(trimmed)) { + return { ok: false, error: 'Ungültiges E-Mail-Format.' }; + } + return { ok: true, value: trimmed }; + } + + // Portal-Login-URL: nur http/https, keine relativen URLs, keine + // Cloud-Metadata/Link-Local/Mcast (ssrfGuard); private Ranges + // nur wenn SSRF_BLOCK_PRIVATE_IPS=true. Trailing-Slash strippen. + if (key === 'portalLoginUrl') { + const trimmed = rawValue.trim().replace(/\/+$/, ''); + if (trimmed === '') return { ok: true, value: '' }; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { ok: false, error: 'Portal-Login-URL muss eine absolute http(s)-URL sein.' }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, error: `Portal-Login-URL: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` }; + } + if (!parsed.hostname) { + return { ok: false, error: 'Portal-Login-URL: Host fehlt.' }; + } + if (isBlockedSsrfHost(parsed.hostname)) { + return { ok: false, error: `Portal-Login-URL: Host '${parsed.hostname}' ist gesperrt (interne/Metadata-Adresse).` }; + } + // Werte mit Pfad/Query sind erlaubt – Mail-Versand hängt ohnehin + // /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt. + return { ok: true, value: trimmed }; + } + + // Default: kein zusätzlicher Format-Check + return { ok: true, value: rawValue }; +} + export async function getSetting(key: string): Promise { const setting = await prisma.appSetting.findUnique({ where: { key }, diff --git a/docs/todo.md b/docs/todo.md index 0b5b8c43..a029689e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -120,6 +120,30 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung - **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf. +- [x] **🛡️ Pentest 2026-05-28 LOW 34.5: Backend-URL-Validierung für AppSettings** + - Schema-Whitelist + Trailing-Slash-Strip standen NUR im Frontend. + API-Endpoint akzeptierte sonst `/relative/path`, `javascript:`, + `ftp://`, `http://192.168.1.1` etc. → Open-Redirect / SSRF-Vektor + in den an Kunden verschickten Portal-Mails. + - Neuer `validateSettingValue(key, value)` in appSetting.service.ts + mit per-Key-Logik: portalLoginUrl → nur http(s), absoluter Host, + `isBlockedSsrfHost`-Check, Trailing-Slash-Strip. Schwellenwerte + (deadline*/documentExpiry*) → positive Integer. Bool-Settings + → strict `true`/`false`. monitoringAlertEmail → RFC-5322-light. + - Controller (updateSetting + updateSettings) ruft Validator nach + der HTML-Strip-Sanitisierung; bei Fehler 400 mit aussagekräftiger + Message. Bulk-PUT validiert ALLE Werte bevor irgendwas gespeichert + wird (kein halb-committed-State bei einem ungültigen Eintrag). + - **Live-verifiziert** auf dev: + - `/evil/path` → 400 "muss absolute http(s)-URL sein" + - `javascript:alert(1)` → 400 (durch stripHtml zu blocked: → Validator: unzulässiges Schema) + - `ftp://evil.com` / `data:text/html` → 400 + - `http://169.254.169.254` → 400 (Cloud-Metadata immer geblockt) + - `http://192.168.1.1` → 200 (on-prem-Default; mit SSRF_BLOCK_PRIVATE_IPS=true → 400) + - `https://crm.example.de/` → DB: `https://crm.example.de` (Slash gestrippt) + - `https://crm.example.de//abc/` → DB: `https://crm.example.de//abc` + (nur trailing slash; doppelte slashes mittendrin bleiben) + - [x] **🐛 Bugfix: Portal-Passwörter in Verträgen wurden mutiliert** - Folgefehler aus Pentest 31.1 (Stored-XSS-Strip): die rekursive `sanitizeContractBody`-Funktion lief auch über `portalPassword`.