Security-Hardening Runde 14: Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip
Pentest Runde 11:
C2 KRITISCH – Factory Reset ohne Bestätigung:
Eingeloggter Admin konnte mit leerem oder beliebigem Body die DB
plätten (3× in einer Pentest-Session passiert). Server erzwingt jetzt
confirm:"FACTORY-RESET-BESTAETIGT" als String. Frontend-API sendet
den Wert automatisch mit.
M1 – Settings Mass Assignment:
PUT /api/settings akzeptierte beliebige Keys (superAdminEmail,
debugMode, allowedOrigins). Neue Whitelist ALLOWED_SETTING_KEYS in
appSetting.service.ts; updateSetting + updateSettings prüfen jeden
Key, unbekannte → 400.
M3 – Prisma-Error-Leak:
Statt 30+ Controller einzeln zu fixen, globaler res.json()-Wrapper
unter /api: error/details-Strings werden durch Pattern-Filter
geschickt, der ORM-/Stack-Trace-Muster zu "Operation fehlgeschlagen"
ersetzt. Original bleibt im Server-Log.
M2 – Stored XSS in Customer/User-Strings:
Neuer stripHtml()-Helper. pickCustomerUpdate/Create + pickUserUpdate/
Create rufen ihn auf jeden String-Wert. Defense-in-Depth gegen PDF/
E-Mail-Template-XSS-Vektoren – React-Frontend ist eh auto-escaped.
Live-verifiziert:
- factory-reset {} / {confirm:true} / {confirm:false} → 400, DB ok
- PUT /settings {superAdminEmail,...} → 400 + Keys aufgezählt;
PUT /settings {customerSupportTicketsEnabled:"true"} → 200
- PUT /users/99999 → "Operation fehlgeschlagen" (vorher Prisma-Stack)
- PUT /customers/3 {companyName:"<script>...</script>EvilCorp"} →
gespeichert als "EvilCorp"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -216,32 +216,54 @@ const USER_UPDATABLE_FIELDS = [
|
||||
|
||||
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
|
||||
|
||||
/**
|
||||
* Strippt HTML-Tags und Script-/Style-Inhalt aus einem String, damit ein
|
||||
* gespeicherter Wert nicht später irgendwo zum aktiven XSS-Vektor wird
|
||||
* (z.B. PDF-Generator, E-Mail-Template oder ein dangerouslySetInnerHTML
|
||||
* im Frontend). React-Auto-Escaping fängt den normalen Fall ab, aber
|
||||
* Defense-in-Depth speichert lieber gleich nichts Bösartiges.
|
||||
* Pentest Runde 11 (2026-05-18), M2: <script>alert(1)</script> in
|
||||
* companyName landete vorher ungefiltert in der DB.
|
||||
*/
|
||||
export function stripHtml(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value
|
||||
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<\/?[a-z][^>]*>/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
||||
* Optional werden alle String-Werte durch stripHtml geschickt.
|
||||
*/
|
||||
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
|
||||
function pick<T extends object>(obj: T, allowed: readonly string[], options: { stripHtmlFromStrings?: boolean } = {}): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
for (const key of allowed) {
|
||||
if (key in obj) {
|
||||
(result as any)[key] = (obj as any)[key];
|
||||
let v = (obj as any)[key];
|
||||
if (options.stripHtmlFromStrings && typeof v === 'string') {
|
||||
v = stripHtml(v);
|
||||
}
|
||||
(result as any)[key] = v;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
|
||||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||||
}
|
||||
|
||||
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
|
||||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||||
}
|
||||
|
||||
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
|
||||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS, { stripHtmlFromStrings: true });
|
||||
}
|
||||
|
||||
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, USER_CREATE_FIELDS);
|
||||
return pick((body as object) || {}, USER_CREATE_FIELDS, { stripHtmlFromStrings: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user