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:
@@ -41,6 +41,15 @@ export async function updateSetting(req: AuthRequest, res: Response): Promise<vo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist-Check (Pentest Runde 11, M1)
|
||||||
|
if (!appSettingService.isAllowedSettingKey(key)) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Unbekannter Setting-Key: ${key}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 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 ?? '-';
|
||||||
@@ -78,6 +87,18 @@ export async function updateSettings(req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whitelist-Check für jeden Key (Pentest Runde 11, M1: Mass Assignment)
|
||||||
|
const unknownKeys = Object.keys(settings).filter(
|
||||||
|
(k) => !appSettingService.isAllowedSettingKey(k),
|
||||||
|
);
|
||||||
|
if (unknownKeys.length > 0) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: `Unbekannte Setting-Keys: ${unknownKeys.join(', ')}`,
|
||||||
|
} as ApiResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vorherige Werte laden für Audit
|
// Vorherige Werte laden für Audit
|
||||||
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
const changes: Record<string, { von: unknown; nach: unknown }> = {};
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
|
|||||||
@@ -176,6 +176,22 @@ export async function uploadBackup(req: Request, res: Response) {
|
|||||||
*/
|
*/
|
||||||
export async function factoryReset(req: Request, res: Response) {
|
export async function factoryReset(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
// Bestätigung erforderlich: client MUSS explizit
|
||||||
|
// `confirm: "FACTORY-RESET-BESTAETIGT"` schicken. Ohne diesen Schritt
|
||||||
|
// konnte ein eingeloggter Admin die komplette DB mit einem einfachen
|
||||||
|
// POST plätten (Pentest Runde 11 (2026-05-18) – C2 KRITISCH:
|
||||||
|
// 3× DB-Plättung in einer Session). Body-Wert ist absichtlich ein
|
||||||
|
// unique String und kein boolean, damit kein Auto-JSON-Tooling /
|
||||||
|
// Replay-Angriff aus Versehen triggern kann.
|
||||||
|
const confirm = (req.body && req.body.confirm) ? String(req.body.confirm) : '';
|
||||||
|
if (confirm !== 'FACTORY-RESET-BESTAETIGT') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Bestätigung fehlt. Body muss { "confirm": "FACTORY-RESET-BESTAETIGT" } enthalten.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const result = await backupService.factoryReset();
|
const result = await backupService.factoryReset();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -190,6 +206,7 @@ export async function factoryReset(req: Request, res: Response) {
|
|||||||
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
res.status(500).json({ error: 'Werkseinstellungen fehlgeschlagen', details: result.error });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
res.status(500).json({ error: 'Fehler bei Werkseinstellungen', details: error.message });
|
res.status(500).json({ error: 'Fehler bei Werkseinstellungen' });
|
||||||
|
console.error('factoryReset error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,44 @@ app.use('/api', (_req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Globaler Sanitizer für Fehler-Antworten: bekannte ORM-/Stack-Trace-Muster
|
||||||
|
// in `error`/`details`-Strings ersetzen, bevor sie an den Client gehen.
|
||||||
|
// So leakten frühere Builds bei z.B. `PUT /api/users/99999` rohe
|
||||||
|
// Prisma-Internals wie "Invalid `prisma.user.update()` invocation:
|
||||||
|
// Record to update not found" (Pentest Runde 11 M3). Der Original-Text
|
||||||
|
// landet weiterhin im Server-Log.
|
||||||
|
const ORM_LEAK_PATTERNS: RegExp[] = [
|
||||||
|
/Invalid `prisma\./i,
|
||||||
|
/PrismaClient/i,
|
||||||
|
/^\s*at\s+[A-Za-z]+\s+\(/m, // Stack-Frame
|
||||||
|
/at\s+[A-Za-z][\w.<>]*\s*\([^)]*:\d+:\d+\)/, // file:line:col
|
||||||
|
];
|
||||||
|
function sanitizeErrorString(s: string): string {
|
||||||
|
if (!s) return s;
|
||||||
|
for (const re of ORM_LEAK_PATTERNS) {
|
||||||
|
if (re.test(s)) {
|
||||||
|
console.error('[orm-leak-guard] Maskierte Fehlermeldung:', s.slice(0, 300));
|
||||||
|
return 'Operation fehlgeschlagen';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
app.use('/api', (_req, res, next) => {
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
res.json = (body: any) => {
|
||||||
|
if (body && typeof body === 'object') {
|
||||||
|
if (typeof body.error === 'string') {
|
||||||
|
body.error = sanitizeErrorString(body.error);
|
||||||
|
}
|
||||||
|
if (typeof body.details === 'string') {
|
||||||
|
body.details = sanitizeErrorString(body.details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalJson(body);
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
// Numerische ID-Parameter strikt validieren. parseInt('6abc') liefert 6, was
|
||||||
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
// dazu führt, dass `/api/customers/6abc` als `/api/customers/6` interpretiert
|
||||||
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
// wurde – kein Auth-Bypass (Prisma fängt SQL-Injection), aber fehlende Input-
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ const DEFAULT_SETTINGS: Record<string, string> = {
|
|||||||
documentExpiryWarningDays: '90', // Gelb: Warnung (Standard 90 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',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isAllowedSettingKey(key: string): boolean {
|
||||||
|
return ALLOWED_SETTING_KEYS.has(key);
|
||||||
|
}
|
||||||
|
|
||||||
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 },
|
||||||
|
|||||||
@@ -216,32 +216,54 @@ const USER_UPDATABLE_FIELDS = [
|
|||||||
|
|
||||||
const USER_CREATE_FIELDS = 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.
|
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||||||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
* 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> = {};
|
const result: Partial<T> = {};
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (key in obj) {
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
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>> {
|
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>> {
|
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>> {
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,44 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🚨 Pentest Runde 11 – Factory-Reset, Settings-Whitelist, Prisma-Leak, XSS-Strip**
|
||||||
|
- **C2 KRITISCH – Factory Reset ohne Bestätigung**:
|
||||||
|
Eingeloggter Admin konnte mit leerem oder beliebigem Body
|
||||||
|
(`{confirm:true}`, `{confirm:false}`, `{}`) die komplette DB
|
||||||
|
plätten (3× in einer Session passiert). Fix: server-side wird
|
||||||
|
jetzt `confirm: "FACTORY-RESET-BESTAETIGT"` als String erzwungen,
|
||||||
|
sonst HTTP 400. Frontend-API schickt den exakten String mit.
|
||||||
|
- **M1 – Settings Mass Assignment**:
|
||||||
|
`PUT /api/settings` und `PUT /api/settings/:key` nahmen JEDEN
|
||||||
|
Key-Value-Pair an (`superAdminEmail`, `debugMode`,
|
||||||
|
`allowedOrigins` etc. landeten direkt in der DB). Fix:
|
||||||
|
Whitelist `ALLOWED_SETTING_KEYS` in `appSetting.service.ts`,
|
||||||
|
Helper `isAllowedSettingKey()`. Unbekannte Keys → HTTP 400 mit
|
||||||
|
expliziter Aufzählung der ungültigen Keys.
|
||||||
|
- **M3 – Prisma-Error-Leak in jeder Response**:
|
||||||
|
Statt 30+ Controller einzeln zu fixen: globaler `res.json()`-
|
||||||
|
Wrapper unter `/api`, der `error`/`details`-Strings durch einen
|
||||||
|
Pattern-Filter schickt. Bekannte ORM-/Stack-Trace-Muster
|
||||||
|
(`Invalid \`prisma.`, `PrismaClient`, Stack-Frames) werden zu
|
||||||
|
`"Operation fehlgeschlagen"` ersetzt. Original-Text bleibt im
|
||||||
|
Server-Log via `[orm-leak-guard]`.
|
||||||
|
- **M2 – Stored XSS in Customer/User-Strings**:
|
||||||
|
`<script>alert(1)</script>` und ähnliche Payloads landeten
|
||||||
|
ungefiltert in der DB. Fix: neuer `stripHtml()`-Helper, von
|
||||||
|
`pickCustomerUpdate/Create` und `pickUserUpdate/Create` auf
|
||||||
|
allen String-Werten angewandt (Defense-in-Depth – React
|
||||||
|
auto-escaped schon, aber PDF-Generator/E-Mail-Templates
|
||||||
|
könnten exec-Vektoren sein).
|
||||||
|
- **Live-verifiziert (alle vier)**:
|
||||||
|
* `/factory-reset` mit `{}`, `{confirm:true}`, `{confirm:false}`
|
||||||
|
→ HTTP 400, DB unangetastet
|
||||||
|
* `PUT /settings {superAdminEmail,debugMode,allowedOrigins}` →
|
||||||
|
400 + Keys aufgezählt; gültige Keys → 200
|
||||||
|
* `PUT /users/99999` → `"Operation fehlgeschlagen"` statt
|
||||||
|
Prisma-Stack; Server-Log behält Original
|
||||||
|
* `PUT /customers/3 {companyName:"<script>...</script>EvilCorp"}`
|
||||||
|
→ gespeichert als `"EvilCorp"`; `<img onerror>` weg
|
||||||
|
|
||||||
- [x] **🚨 Pentest Runde 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
|
- [x] **🚨 Pentest Runde 10 – Live-Vollmacht-Konsistenz + DTO-Leaks in embedded Objekten**
|
||||||
- **MEDIUM – Stale Token nach Vollmacht-Widerruf**:
|
- **MEDIUM – Stale Token nach Vollmacht-Widerruf**:
|
||||||
Selbst ein FRISCHER Portal-Login lieferte JWT mit
|
Selbst ein FRISCHER Portal-Login lieferte JWT mit
|
||||||
|
|||||||
@@ -1002,7 +1002,12 @@ export const backupApi = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
factoryReset: async () => {
|
factoryReset: async () => {
|
||||||
const res = await api.post<ApiResponse<void>>('/settings/factory-reset');
|
// Server erzwingt confirm-Body als Schutz gegen versehentliche
|
||||||
|
// DB-Plättung (Pentest Runde 11). Der Confirm-String muss exakt
|
||||||
|
// dieser Wert sein.
|
||||||
|
const res = await api.post<ApiResponse<void>>('/settings/factory-reset', {
|
||||||
|
confirm: 'FACTORY-RESET-BESTAETIGT',
|
||||||
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user