Files
opencrm/backend/src/services/appSetting.service.ts
T
duffyduckandClaude Opus 4.7 d0d2715baa Pentest 46.1 HIGH + Info-Konsolidierung: zentrale URL-Validierung
46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.

Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
  vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
  isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt

Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
  (Refactor der bestehenden ad-hoc Validierung → konsolidiert)

Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.

INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 11:48:14 +02:00

185 lines
6.9 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 { validateHttpUrl } from '../utils/url.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 privaten/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. (Pentest Runde 35 LOW 34.5-followup, on-prem-Override
// SSRF_BLOCK_PRIVATE_IPS gilt hier explizit NICHT.)
// Werte mit Pfad/Query sind erlaubt Mail-Versand hängt ohnehin
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
if (key === 'portalLoginUrl') {
return validateHttpUrl(rawValue, { fieldLabel: 'Portal-Login-URL' });
}
// 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;
}