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:
@@ -63,16 +63,45 @@ async function cleanupXss() {
|
|||||||
console.log(` → User bereinigt: ${userTouched}`);
|
console.log(` → User bereinigt: ${userTouched}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HTML in Plain-Text-Settings strippen: WYSIWYG-Editoren liefern
|
||||||
|
// absichtlich HTML, alles andere (companyName, defaultEmailDomain, ...)
|
||||||
|
// muss reiner Text bleiben. Pentest 2026-05-19, MEDIUM.
|
||||||
|
const HTML_ALLOWED_SETTING_KEYS = new Set([
|
||||||
|
'authorizationTemplateHtml',
|
||||||
|
'imprintHtml',
|
||||||
|
'privacyPolicyHtml',
|
||||||
|
'websitePrivacyPolicyHtml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stripHtmlString(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<\/?[a-z][^>]*>/gi, '');
|
||||||
|
}
|
||||||
|
|
||||||
async function cleanupAppSettings() {
|
async function cleanupAppSettings() {
|
||||||
const settings = await prisma.appSetting.findMany();
|
const settings = await prisma.appSetting.findMany();
|
||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
|
let stripped = 0;
|
||||||
for (const s of settings) {
|
for (const s of settings) {
|
||||||
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
|
if (!ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||||
removed.push(s.key);
|
removed.push(s.key);
|
||||||
await prisma.appSetting.delete({ where: { key: s.key } });
|
await prisma.appSetting.delete({ where: { key: s.key } });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!HTML_ALLOWED_SETTING_KEYS.has(s.key)) {
|
||||||
|
const cleaned = stripHtmlString(s.value);
|
||||||
|
if (cleaned !== s.value) {
|
||||||
|
await prisma.appSetting.update({ where: { key: s.key }, data: { value: cleaned } });
|
||||||
|
stripped++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
|
console.log(` → AppSettings entfernt: ${removed.length}${removed.length ? ' (' + removed.join(', ') + ')' : ''}`);
|
||||||
|
if (stripped > 0) {
|
||||||
|
console.log(` → AppSettings HTML-gestrippt: ${stripped}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
|
// Pattern, die auf typische Pentest-/Test-Daten hindeuten. Bewusst eng
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
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);
|
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)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
const before = await prisma.appSetting.findUnique({ where: { key } });
|
const before = await prisma.appSetting.findUnique({ where: { key } });
|
||||||
const oldValue = before?.value ?? '-';
|
const oldValue = before?.value ?? '-';
|
||||||
const newValue = String(value);
|
const newValue = appSettingService.sanitizeSettingValue(key, String(value));
|
||||||
if (oldValue !== newValue) {
|
if (oldValue !== newValue) {
|
||||||
changes[key] = { von: oldValue, nach: newValue };
|
changes[key] = { von: oldValue, nach: newValue };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import prisma from '../lib/prisma.js';
|
import prisma from '../lib/prisma.js';
|
||||||
|
import { stripHtml } from '../utils/sanitize.js';
|
||||||
|
|
||||||
// Default settings
|
// Default settings
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
@@ -33,6 +34,32 @@ export function isAllowedSettingKey(key: string): boolean {
|
|||||||
return ALLOWED_SETTING_KEYS.has(key);
|
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> {
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
const setting = await prisma.appSetting.findUnique({
|
const setting = await prisma.appSetting.findUnique({
|
||||||
where: { key },
|
where: { key },
|
||||||
|
|||||||
@@ -120,6 +120,24 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
- **Live-verifiziert**: 4867 Datensätze + 1 Datei in 13.2s
|
||||||
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
wiederhergestellt, Log-Modal zeigt den vollständigen Verlauf.
|
||||||
|
|
||||||
|
- [x] **🛡️ XSS-Sanitization für Plain-Text-AppSettings (Pentest MEDIUM)**
|
||||||
|
- `companyName` (und weitere Plain-Text-Keys wie `defaultEmailDomain`,
|
||||||
|
`monitoringAlertEmail`, Schwellenwerte) konnten via PUT
|
||||||
|
`/api/settings/:key` mit XSS-Payloads befüllt werden – das war
|
||||||
|
nur Admin-triggerbar, aber E-Mail-Templates/PDF-Generatoren
|
||||||
|
hätten den Wert ungescaped rendern können.
|
||||||
|
- Fix: neuer `sanitizeSettingValue(key, value)` in
|
||||||
|
`appSetting.service.ts` strippt HTML außer für die expliziten
|
||||||
|
HTML-Editor-Keys (`imprintHtml`, `privacyPolicyHtml`,
|
||||||
|
`authorizationTemplateHtml`, `websitePrivacyPolicyHtml`).
|
||||||
|
Greift in `updateSetting` (Einzel) und `updateSettings` (Bulk).
|
||||||
|
- Cleanup-Skript erweitert: bestehende AppSettings mit HTML in
|
||||||
|
Plain-Text-Keys werden beim Container-Start gestrippt
|
||||||
|
(idempotent).
|
||||||
|
- **Live-verifiziert** auf dev: `<img src=x onerror=alert(1)>OpenCRM
|
||||||
|
<script>alert(2)</script>` via PUT → DB-Wert: `"OpenCRM"`.
|
||||||
|
`imprintHtml` mit `<h1><p>` → unverändert.
|
||||||
|
|
||||||
- [x] **🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)**
|
- [x] **🐛 Rollen-Perms-Sync beim Container-Start (Follow-up DSGVO-Fix)**
|
||||||
- Bestehende Installationen liefen weiter mit veraltetem
|
- Bestehende Installationen liefen weiter mit veraltetem
|
||||||
Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
|
Permission-Set für die DSGVO-Rolle (audit:read u.a. fehlten),
|
||||||
|
|||||||
Reference in New Issue
Block a user