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>
185 lines
6.9 KiB
TypeScript
185 lines
6.9 KiB
TypeScript
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;
|
||
}
|