Files
opencrm/backend/src/services/appSetting.service.ts
T
duffyduck 7dcdf9d6ef Pentest Runde 35 follow-up: portalLoginUrl blockt ALLE privaten IPs
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>
2026-05-28 21:55:44 +02:00

208 lines
8.1 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 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;
}