import prisma from '../lib/prisma.js'; import { stripHtml } from '../utils/sanitize.js'; import { isPrivateOrBlockedHost } from '../utils/ssrfGuard.js'; // Default settings const DEFAULT_SETTINGS: Record = { customerSupportTicketsEnabled: 'false', // Vertrags-Cockpit: Fristenschwellen (in Tagen) deadlineCriticalDays: '14', // Rot: Kritisch deadlineWarningDays: '42', // Gelb: Warnung (6 Wochen) deadlineOkDays: '90', // Grün: OK (3 Monate) // Ausweis-Ablauf: Fristenschwellen (in Tagen) documentExpiryCriticalDays: '30', // Rot: Kritisch (Standard 30 Tage) documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 Tage) }; // Whitelist erlaubter Setting-Keys. PUT /api/settings nimmt KEINE // anderen Keys mehr an (Pentest Runde 11 (2026-05-18) – M1: Mass // Assignment, "superAdminEmail", "debugMode", "allowedOrigins" landeten // vorher ungefiltert in der DB). export const ALLOWED_SETTING_KEYS: ReadonlySet = new Set([ ...Object.keys(DEFAULT_SETTINGS), 'authorizationTemplateHtml', 'imprintHtml', 'privacyPolicyHtml', 'websitePrivacyPolicyHtml', 'monitoringAlertEmail', 'monitoringDigestEnabled', 'monitoringLastDigestAt', 'companyName', 'defaultEmailDomain', // Basis-URL für an Kunden verschickte Portal-Links (Login + Passwort-Reset). // Vorher kam aus `PUBLIC_URL`-Env, default localhost – Mails enthielten // dann unklickbare Links. Wird in Settings → Kundenportal gepflegt. 'portalLoginUrl', ]); export function isAllowedSettingKey(key: string): boolean { return ALLOWED_SETTING_KEYS.has(key); } // Keys deren Wert legitim HTML enthalten darf (Datenschutz-/Impressum-Editoren // liefern WYSIWYG-HTML). Alle anderen Plain-Text-Keys (companyName, // defaultEmailDomain, Schwellenwerte etc.) werden vor dem Persistieren durch // stripHtml geschickt – Pentest 2026-05-19, MEDIUM: // in companyName landete ungefiltert in der DB und konnte später z.B. in // E-Mail-Templates oder PDF-Generatoren unescaped landen. const HTML_ALLOWED_SETTING_KEYS: ReadonlySet = new Set([ 'authorizationTemplateHtml', 'imprintHtml', 'privacyPolicyHtml', 'websitePrivacyPolicyHtml', ]); /** * Bereinigt den Wert vor dem Speichern: für Plain-Text-Keys werden alle * HTML-Tags entfernt. Die dedizierten Editor-Keys * (imprintHtml/privacyPolicyHtml/...) bleiben unverändert, da sie sonst * den WYSIWYG-Editor unbenutzbar machen würden – sie werden über * dedizierte /api/gdpr-Endpoints gepflegt. */ export function sanitizeSettingValue(key: string, value: string): string { if (HTML_ALLOWED_SETTING_KEYS.has(key)) return value; const stripped = stripHtml(value); 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 // privaten oder loopback-Hosts. Strikter als `isBlockedSsrfHost`, // weil der Wert in Mails an Endkunden landet – die können // 127.0.0.1/10.x/172.16.x/192.168.x ohnehin nicht erreichen, // also gibt's keinen legitimen Grund für so eine URL hier. // (Pentest Runde 35 LOW 34.5-followup, on-prem-Override SSRF_BLOCK_ // PRIVATE_IPS gilt hier explizit NICHT.) Trailing-Slash wird gestrippt. 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.' }; } // Node's URL-Parser lässt eckige Klammern im hostname für IPv6 // (`http://[::1]` → hostname `"[::1]"`). Klammern strippen, sonst // matcht der Loopback-Pattern `^::1$` nicht. const hostForCheck = parsed.hostname.replace(/^\[|\]$/g, ''); if (isPrivateOrBlockedHost(hostForCheck)) { return { ok: false, error: `Portal-Login-URL: Host '${hostForCheck}' ist gesperrt (interne, private oder Loopback-Adresse). Bitte öffentlich erreichbare Domain verwenden.` }; } // 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 }, }); if (setting) { return setting.value; } // Return default if exists return DEFAULT_SETTINGS[key] ?? null; } export async function getSettingBool(key: string): Promise { const value = await getSetting(key); return value === 'true'; } export async function setSetting(key: string, value: string): Promise { await prisma.appSetting.upsert({ where: { key }, update: { value }, create: { key, value }, }); } export async function getAllSettings(): Promise> { const settings = await prisma.appSetting.findMany(); // Start with defaults, then override with stored values const result = { ...DEFAULT_SETTINGS }; for (const setting of settings) { result[setting.key] = setting.value; } return result; } export async function getPublicSettings(): Promise> { // Settings that should be available to all authenticated users (including customers) const publicKeys = ['customerSupportTicketsEnabled']; const allSettings = await getAllSettings(); const result: Record = {}; for (const key of publicKeys) { if (key in allSettings) { result[key] = allSettings[key]; } } return result; }