Security-Hardening Runde 12: Information-Disclosure + Input-Validation
Pentest Runde 7 (Anschlussrunde):
MEDIUM – Interne Felder in Portal-Responses:
- sanitizeCustomerStrict strippt zusätzlich portalTokenInvalidatedAt,
portalLastLogin, portalPasswordMustChange, lastBirthdayGreetingYear,
privacyPolicyPath, businessRegistrationPath, commercialRegisterPath.
- Neue sanitizeContract/Strict + sanitizeContracts/Strict: entfernt
portalPasswordEncrypted immer (nur über /password-Endpoint mit Audit
abrufbar), für Portal-User zusätzlich commission/notes/nextReviewDate.
- getContract + getContracts wählen je nach isCustomerPortal die
passende Variante. Mitarbeiter sehen commission/notes weiterhin.
LOW – Integer-Truncation bei IDs:
parseInt('6abc') → 6 lief vorher durch. Neue Heuristik-Middleware
unter /api: jedes Pfad-Segment, das mit Ziffer beginnt aber nicht
aus reinen Ziffern besteht, wird mit 400 abgelehnt. Trifft alle
Sub-Router ohne dass jede Route einzeln angefasst werden muss.
INFO – Rate-Limit: Code-Stand limit=10 für Login, limit=5 für
Password-Reset (lokal verifiziert: 11. failed login = 429). Pentester
sah vermutlich noch älteren Build. Kein Code-Change.
Live-verifiziert:
- /customers/6abc → 400 "Ungültige ID im URL-Pfad"
- /customers/3 → 200, /contracts/1abc/history → 400, normale Pfade OK
- Portal-User /customers/3: keine portalLastLogin/portalPasswordMustChange/
portalTokenInvalidatedAt/etc. mehr in Response
- Portal-User /contracts/15: keine commission/notes/portalPasswordEncrypted/
nextReviewDate
- Admin /contracts/15: commission/notes/nextReviewDate sichtbar,
portalPasswordEncrypted weg
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,37 @@ const SENSITIVE_CUSTOMER_FIELDS = [
|
||||
'consentHash',
|
||||
] as const;
|
||||
|
||||
// Zusätzliche Felder die Portal-User nicht in ihrer Customer-Response sehen
|
||||
// sollen – Interne Session-/Workflow-State, kein direkter Auth-Bypass, aber
|
||||
// unnötige Informationsleckage über den DB-Aufbau.
|
||||
// Pentest Runde 7 (2026-05-17), MEDIUM.
|
||||
const PORTAL_HIDDEN_CUSTOMER_FIELDS = [
|
||||
'portalTokenInvalidatedAt',
|
||||
'portalLastLogin',
|
||||
'portalPasswordMustChange',
|
||||
'lastBirthdayGreetingYear',
|
||||
// privacyPolicyPath etc. sind interne Datei-Pfade – Portal nutzt
|
||||
// dedizierte PDF-Endpoints, nicht den Pfad direkt
|
||||
'privacyPolicyPath',
|
||||
'businessRegistrationPath',
|
||||
'commercialRegisterPath',
|
||||
] as const;
|
||||
|
||||
// Felder die im Contract NIE rausgehen dürfen (auch nicht an Mitarbeiter).
|
||||
// portalPasswordEncrypted ist nur über den dedizierten /password-Endpoint
|
||||
// (mit Audit-Log) abrufbar – im /contracts/:id selbst nutzlos.
|
||||
const SENSITIVE_CONTRACT_FIELDS = [
|
||||
'portalPasswordEncrypted',
|
||||
] as const;
|
||||
|
||||
// Zusätzliche Felder die Portal-User nicht sehen sollen (interne CRM-Daten).
|
||||
// Pentest Runde 7 (2026-05-17): commission + notes leakten an Portal-User.
|
||||
const PORTAL_HIDDEN_CONTRACT_FIELDS = [
|
||||
'commission',
|
||||
'notes',
|
||||
'nextReviewDate', // Snooze-Workflow ist internes Cockpit-Feature
|
||||
] as const;
|
||||
|
||||
const SENSITIVE_USER_FIELDS = [
|
||||
'password',
|
||||
'passwordResetToken',
|
||||
@@ -43,14 +74,18 @@ export function sanitizeCustomer<T extends Record<string, unknown>>(customer: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern.
|
||||
* Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde).
|
||||
* Entfernt portalPasswordEncrypted + portal-interne Workflow-Felder zusätzlich
|
||||
* zu den allgemein sensiblen Feldern. Für Kontexte in denen der Caller KEIN
|
||||
* Admin ist (z.B. Portal-Kunde).
|
||||
*/
|
||||
export function sanitizeCustomerStrict<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||||
if (!customer) return customer;
|
||||
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
|
||||
if (!copy) return null;
|
||||
delete copy.portalPasswordEncrypted;
|
||||
for (const field of PORTAL_HIDDEN_CUSTOMER_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
@@ -61,6 +96,49 @@ export function sanitizeCustomers<T extends Record<string, unknown>>(customers:
|
||||
return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Contract-Objekt für alle Caller. Entfernt das verschlüsselte
|
||||
* Provider-Passwort (nur über den dedizierten /password-Endpoint mit
|
||||
* Audit-Log abrufbar) und sanitisiert das embedded customer.
|
||||
*/
|
||||
export function sanitizeContract<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||||
if (!contract) return contract;
|
||||
const copy: Record<string, unknown> = { ...contract };
|
||||
for (const field of SENSITIVE_CONTRACT_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
if (copy.customer && typeof copy.customer === 'object') {
|
||||
copy.customer = sanitizeCustomer(copy.customer as Record<string, unknown>);
|
||||
}
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize Contract für Portal-User: zusätzlich werden interne CRM-Felder
|
||||
* (Provision, Notizen, Snooze-Date) gestrippt und das embedded customer
|
||||
* mit `sanitizeCustomerStrict` gefiltert. Pentest Runde 7 (2026-05-17).
|
||||
*/
|
||||
export function sanitizeContractStrict<T extends Record<string, unknown>>(contract: T | null): T | null {
|
||||
if (!contract) return contract;
|
||||
const copy = sanitizeContract(contract) as Record<string, unknown> | null;
|
||||
if (!copy) return null;
|
||||
for (const field of PORTAL_HIDDEN_CONTRACT_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
if (copy.customer && typeof copy.customer === 'object') {
|
||||
copy.customer = sanitizeCustomerStrict(copy.customer as Record<string, unknown>);
|
||||
}
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
export function sanitizeContracts<T extends Record<string, unknown>>(contracts: T[]): T[] {
|
||||
return contracts.map((c) => sanitizeContract(c)).filter((c): c is T => c !== null);
|
||||
}
|
||||
|
||||
export function sanitizeContractsStrict<T extends Record<string, unknown>>(contracts: T[]): T[] {
|
||||
return contracts.map((c) => sanitizeContractStrict(c)).filter((c): c is T => c !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize User-Objekt für API-Responses.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user