Security-Hardening Runde 2: Zip-Slip, Mass Assignment, weitere IDORs, Path-Traversal
Nach der ersten Runde habe ich parallel 3 Audit-Agents auf die Codebase angesetzt. Die fanden noch eine Menge: Zip-Slip, Mass Assignment inkl. Privilege Escalation, 13 weitere IDOR-Stellen, 2x Path-Traversal. Alles gefixt. Details + Angriffsvektoren in docs/SECURITY-REVIEW.md. 🔴 KRITISCH gefixt: 1. Zip-Slip im Backup-Upload: extractAllTo() entpackte bösartige ZIPs ohne Pfad-Validierung. Ein Angreifer mit Admin-Zugang hätte mit einem ZIP mit Entries wie ../../etc/crontab das ganze Filesystem überschreiben können. Jetzt wird jeder ZIP-Entry einzeln validiert (path.resolve, starts-with-Check). Absolute Pfade + Null-Bytes werden abgelehnt. 2. Mass Assignment bei Customer/User Controllers: - updateCustomer/createCustomer: req.body ging komplett an Prisma. Angreifer konnte portalPasswordHash, portalPasswordResetToken, consentHash, customerNumber direkt setzen. - updateUser/createUser: roleIds und isActive waren übernehmbar. **Privilege Escalation**: normaler Mitarbeiter konnte sich Admin-Rechte durch PUT /users/:id mit {"roleIds":[1]} geben, oder andere User deaktivieren. Fix: Neue Whitelist-Helper pickCustomerCreate/Update, pickUserCreate/Update in utils/sanitize.ts. Nur erlaubte Felder werden durchgelassen. 3. IDOR bei 13 weiteren Endpoints (neben denen aus Runde 1): - GET /meters/:meterId/readings - GET /emails/:emailId/attachments/:filename - GET /emails/:emailId/attachments (Liste) - GET /customers/:customerId/emails - GET /contracts/:contractId/emails - GET /emails/:id (einzelne Email) - GET /stressfrei-emails/:id (leakte emailPasswordEncrypted) - weitere… Fix: accessControl.ts ausgebaut um canAccessAddress, canAccessBankCard, canAccessIdentityDocument, canAccessMeter, canAccessStressfreiEmail, canAccessCachedEmail. In allen betroffenen Endpoints angewendet. 🟡 WICHTIG gefixt: 4. Path-Traversal bei Backup-Name (GET /settings/backup/:name/*): req.params.name wurde ohne Filter in path.join. Neuer isValidBackupName() erlaubt nur [A-Za-z0-9_-]+ ohne "..". 5. Path-Traversal bei GDPR-Proof-Download: proofDocument-Pfad aus DB wurde ohne Validation gejoined. Jetzt path.resolve + starts-with-uploads-Check. Neue/erweiterte Files: - backend/src/utils/accessControl.ts - 6 neue can-Access-Helper - backend/src/utils/sanitize.ts - 4 neue Whitelist-pick-Helper - docs/SECURITY-REVIEW.md - Runde 2 dokumentiert Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,3 +66,81 @@ export function sanitizeUser<T extends Record<string, unknown>>(user: T | null):
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
// ==================== REQUEST-BODY WHITELISTS ====================
|
||||
// Gegen Mass-Assignment: Nur explizit erlaubte Felder aus req.body übernehmen.
|
||||
|
||||
const CUSTOMER_UPDATABLE_FIELDS = [
|
||||
'type',
|
||||
'salutation',
|
||||
'useInformalAddress',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'companyName',
|
||||
'foundingDate',
|
||||
'birthDate',
|
||||
'birthPlace',
|
||||
'email',
|
||||
'phone',
|
||||
'mobile',
|
||||
'taxNumber',
|
||||
'commercialRegisterNumber',
|
||||
'notes',
|
||||
'portalEnabled',
|
||||
'portalEmail',
|
||||
'autoBirthdayGreeting',
|
||||
'autoBirthdayChannel',
|
||||
// Nicht: portalPasswordHash, portalPasswordEncrypted, portalPasswordResetToken,
|
||||
// portalTokenInvalidatedAt, customerNumber, id, createdAt, updatedAt, consentHash,
|
||||
// lastBirthdayGreetingYear, privacyPolicyPath, businessRegistrationPath, commercialRegisterPath
|
||||
] as const;
|
||||
|
||||
const CUSTOMER_CREATE_FIELDS = [
|
||||
...CUSTOMER_UPDATABLE_FIELDS,
|
||||
// customerNumber wird vom Service generiert – nicht aus req.body übernehmen
|
||||
] as const;
|
||||
|
||||
const USER_UPDATABLE_FIELDS = [
|
||||
'email',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'isActive',
|
||||
'whatsappNumber',
|
||||
'telegramUsername',
|
||||
'signalNumber',
|
||||
'roleIds',
|
||||
'password', // nur Admin, wird im Service gehashed
|
||||
// Nicht: id, customerId, tokenInvalidatedAt, passwordResetToken, passwordResetExpiresAt
|
||||
] as const;
|
||||
|
||||
const USER_CREATE_FIELDS = USER_UPDATABLE_FIELDS;
|
||||
|
||||
/**
|
||||
* Filtert req.body anhand einer Whitelist. Unerlaubte Felder werden verworfen.
|
||||
* Verhindert Mass-Assignment-Angriffe (z.B. { portalPasswordHash: "..." } im Body).
|
||||
*/
|
||||
function pick<T extends object>(obj: T, allowed: readonly string[]): Partial<T> {
|
||||
const result: Partial<T> = {};
|
||||
for (const key of allowed) {
|
||||
if (key in obj) {
|
||||
(result as any)[key] = (obj as any)[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pickCustomerUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, CUSTOMER_UPDATABLE_FIELDS);
|
||||
}
|
||||
|
||||
export function pickCustomerCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, CUSTOMER_CREATE_FIELDS);
|
||||
}
|
||||
|
||||
export function pickUserUpdate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, USER_UPDATABLE_FIELDS);
|
||||
}
|
||||
|
||||
export function pickUserCreate(body: unknown): Partial<Record<string, unknown>> {
|
||||
return pick((body as object) || {}, USER_CREATE_FIELDS);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user