7dcdf9d6ef
Runde-35-Befund: 34.5 nur teilweise gefixt – Cloud-Metadata (169.254.x.x) wurde blockiert, aber 10/8, 172.16/12, 192.168/16, 127/8 und localhost gingen weiter durch, weil isBlockedSsrfHost diese Ranges nur mit SSRF_BLOCK_PRIVATE_IPS=true geprüft hat. Der Flag steht aber bewusst auf false für on-prem (Plesk auf 127.0.0.1). Threat-Modell-Unterschied: portalLoginUrl ist eine URL in *Endkunden-Mails*. Kunden können 127.0.0.1/192.168.x.x ohnehin nicht erreichen → kein legitimer Wert. Daher muss der Check hier strikt sein, unabhängig vom on-prem-Flag (der gilt nur für ausgehende Server-zu-Server-Verbindungen wie Provider-Test-Connection). Neuer isPrivateOrBlockedHost() in ssrfGuard.ts: union aus BLOCKED_PATTERNS (Metadata/Multicast/Reserved) und PRIVATE_IP_PATTERNS (10/8, 172.16/12, 192.168/16, 127/8, ::1, fc00::/7) + PRIVATE_HOSTNAMES (localhost, ip6-loopback), egal was SSRF_BLOCK_PRIVATE_IPS sagt. portalLoginUrl-Validator nutzt jetzt isPrivateOrBlockedHost + strippt eckige Klammern aus IPv6-Hostnames (Node URL.hostname liefert "[::1]" inkl. Brackets). Live-verifiziert: 22 Test-Cases (9 Private/Loopback, 4 Schemes, 7 legitime). Auch CIDR-Grenzen (172.15 zulässig, 172.16/31 blockiert, 172.32 zulässig). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
208 lines
8.1 KiB
TypeScript
208 lines
8.1 KiB
TypeScript
import prisma from '../lib/prisma.js';
|
||
import { stripHtml } from '../utils/sanitize.js';
|
||
import { isPrivateOrBlockedHost } from '../utils/ssrfGuard.js';
|
||
|
||
// Default settings
|
||
const DEFAULT_SETTINGS: Record<string, string> = {
|
||
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<string> = 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: <img src=x onerror=alert(1)>
|
||
// 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<string> = 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: <sanitized> }
|
||
* oder { ok: false, error: <message> } 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<string | null> {
|
||
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<boolean> {
|
||
const value = await getSetting(key);
|
||
return value === 'true';
|
||
}
|
||
|
||
export async function setSetting(key: string, value: string): Promise<void> {
|
||
await prisma.appSetting.upsert({
|
||
where: { key },
|
||
update: { value },
|
||
create: { key, value },
|
||
});
|
||
}
|
||
|
||
export async function getAllSettings(): Promise<Record<string, string>> {
|
||
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<Record<string, string>> {
|
||
// Settings that should be available to all authenticated users (including customers)
|
||
const publicKeys = ['customerSupportTicketsEnabled'];
|
||
const allSettings = await getAllSettings();
|
||
|
||
const result: Record<string, string> = {};
|
||
for (const key of publicKeys) {
|
||
if (key in allSettings) {
|
||
result[key] = allSettings[key];
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|