XSS-Sanitization für AppSettings (companyName & Co)

Pentest-Befund (MEDIUM): companyName und weitere Plain-Text-Setting-
Keys nahmen via PUT /api/settings/:key XSS-Payloads wie
<img src=x onerror=alert(1)> ungefiltert entgegen. Nur Admin
triggerbar, aber E-Mail-Templates/PDF-Generatoren hätten den Wert
unescaped rendern können.

Fix in appSetting.service.ts: sanitizeSettingValue(key, value)
strippt HTML außer für die expliziten Editor-Keys (imprintHtml,
privacyPolicyHtml, authorizationTemplateHtml,
websitePrivacyPolicyHtml). Greift in updateSetting + updateSettings.

cleanup-xss-and-mass-assignment.ts bereinigt bestehende dreckige
Werte beim Container-Start (idempotent).

Live-verifiziert auf dev:
- PUT companyName="<img onerror=alert(1)>OpenCRM<script>alert(2)</script>"
  → DB: "OpenCRM"
- Bulk-PUT mit XSS auf companyName + defaultEmailDomain → gestrippt
- imprintHtml mit "<h1>...<p>" → unverändert (HTML-allowed)
- Cleanup-Skript auf dirty value: "EvilCo" statt mit Tags

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 12:49:19 +02:00
parent 8ee5c9b07a
commit b3a6620da6
4 changed files with 79 additions and 2 deletions
@@ -53,7 +53,10 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
// Vorherigen Stand laden für Audit
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
// HTML-Tags aus Plain-Text-Keys strippen, bevor sie in der DB landen.
// Pentest 2026-05-19, MEDIUM: companyName="<img onerror=...>" landete
// sonst ungefiltert in E-Mail-Templates / PDFs.
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
await appSettingService.setSetting(key, newValue);
@@ -104,7 +107,7 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
for (const [key, value] of Object.entries(settings)) {
const before = await prisma.appSetting.findUnique({ where: { key } });
const oldValue = before?.value ?? '-';
const newValue = String(value);
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
if (oldValue !== newValue) {
changes[key] = { von: oldValue, nach: newValue };
}
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -33,6 +34,32 @@ 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);
}
export async function getSetting(key: string): Promise<string | null> {
const setting = await prisma.appSetting.findUnique({
where: { key },