From 94e4fdee23afde5f12d1cadc4537e1eee0c81844 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 13 Aug 2026 19:27:25 +0200 Subject: [PATCH] Mass-Assignment-Schutz: Contract-Create/Update Feld-Whitelist (R158) Letzter Spread-Endpunkt: createContract/updateContract reichten rohen ...contractData an Prisma durch (via as any im Controller). customerId ist zwar legitim aenderbar (Kunden-Select aktiv), aber id/contractNumber/ createdAt/updatedAt/portalPasswordEncrypted und die cancellation*Path-Felder waren so mit-injizierbar. Jetzt explizite Feld-Whitelist (pickContractScalars), konsistent zur R156-Haertung von BankCard/Address/Document. Whitelist autoritativ aus den DB-Spalten abgeleitet - der ContractCreateData- Typ ist unvollstaendig: previousProviderId/previousContractNumber/ previousCustomerNumber/nextReviewDate sind echte Formularfelder, die sonst still gebrochen waeren. cancellation*Path bleiben bewusst draussen (nur ueber die Upload-/Delete-Endpunkte setzbar). Verifiziert: legit Felder (inkl. der 4 zuvor untypisierten) persistieren; injizierte id/contractNumber/cancellationLetterPath werden ignoriert. Co-Authored-By: Claude Opus 4.8 --- backend/src/services/contract.service.ts | 42 ++++++++++++++++++++++-- docs/todo.md | 15 +++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index 0abf6870..74f284df 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -341,6 +341,41 @@ function normalizeMobileNetwork(value: unknown): string | null { return ALLOWED_MOBILE_NETWORKS.has(v) ? v : null; } +// Feld-Whitelist für die Contract-Top-Level-Scalars (Mass-Assignment-Schutz, +// Pentest R158-Hygiene, konsistent zur R156-Härtung von BankCard/Address/ +// Document). Nur diese Felder gehen aus dem (bereits um Nested-Details + +// portalPassword bereinigten) `contractData` an Prisma – NICHT id/contractNumber/ +// createdAt/updatedAt oder sonstige untergeschobene Keys. `contractNumber` wird +// bei create serverseitig gesetzt, nie aus dem Body. +// Autoritativ aus den DB-Spalten abgeleitet (nicht aus dem unvollständigen +// ContractCreateData-Typ!). BEWUSST NICHT enthalten: +// - id / contractNumber / createdAt / updatedAt → nie client-setzbar +// - portalPasswordEncrypted → via portalPassword-Verschlüsselung +// - cancellation*Path (Schreiben/Bestätigung/Optionen) → nur über die +// dedizierten Upload-/Delete-Endpunkte, nicht per Form-Update +const CONTRACT_SCALAR_FIELDS = [ + 'customerId', 'type', 'contractCategoryId', 'status', 'addressId', 'billingAddressId', + 'bankCardId', 'identityDocumentId', 'salesPlatformId', 'previousContractId', 'providerId', + 'tariffId', 'providerName', 'tariffName', 'customerNumberAtProvider', 'orderNumberAtSalesPlatform', + 'customerNumberAtSalesPlatform', 'contractNumberAtSalesPlatform', 'priceFirst12Months', + 'priceFrom13Months', 'priceAfter24Months', 'startDate', 'endDate', 'cancellationPeriodId', + 'contractDurationId', 'commission', 'portalUsername', 'stressfreiEmailId', + 'portalCredentialsNotRequired', 'notes', 'cancellationConfirmationDate', + 'cancellationConfirmationOptionsDate', 'wasSpecialCancellation', + // In ContractCreateData nicht typisiert, aber echte Spalten, die das Formular + // sendet (liefen bisher nur über den rohen Spread durch): + 'previousProviderId', 'previousContractNumber', 'previousCustomerNumber', 'nextReviewDate', +] as const; + +function pickContractScalars(data: unknown): Record { + const src = (data && typeof data === 'object') ? (data as Record) : {}; + const out: Record = {}; + for (const key of CONTRACT_SCALAR_FIELDS) { + if (key in src) out[key] = src[key]; + } + return out; +} + export async function createContract(data: ContractCreateData) { const { energyDetails, @@ -359,7 +394,7 @@ export async function createContract(data: ContractCreateData) { const contract = await prisma.contract.create({ data: { - ...contractData, + ...(pickContractScalars(contractData) as any), contractNumber: generateContractNumber(data.type), portalPasswordEncrypted, ...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type) @@ -489,11 +524,12 @@ export async function updateContract( // in diesem Update wirklich auf true gesetzt wird (nicht bei jedem PUT). const clearCredentials = contractData.portalCredentialsNotRequired === true; - // Update main contract + // Update main contract – nur Whitelist-Felder (kein roher `...contractData` + // mehr → kein Mass-Assignment von id/customerId-PK/contractNumber/Timestamps). await prisma.contract.update({ where: { id }, data: { - ...contractData, + ...(pickContractScalars(contractData) as any), ...(clearCredentials ? { portalUsername: null, diff --git a/docs/todo.md b/docs/todo.md index 89aacd41..9fb1be09 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,21 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Mass-Assignment-Schutz: Contract-Create/Update (Pentest R158-Hygiene)** (2026-08-13) + - Letzter Spread-Endpunkt (`createContract`/`updateContract` spreadeten rohen + `...contractData` an Prisma) auf eine **Feld-Whitelist** umgestellt – konsistent + zur R156-Härtung (BankCard/Address/Document). `id`/`contractNumber`/`createdAt`/ + `updatedAt`/`portalPasswordEncrypted` und alle `cancellation*Path`-Felder sind + damit **nicht** mehr per Form-Update setzbar (Pfade nur noch über die + Upload-Endpunkte). + - **Whitelist autoritativ aus den DB-Spalten** abgeleitet (nicht aus dem + unvollständigen `ContractCreateData`-Typ!) – dabei fielen 4 echte, vom Formular + gesendete Felder auf, die NICHT im Typ standen und sonst still gebrochen wären: + `previousProviderId`, `previousContractNumber`, `previousCustomerNumber`, + `nextReviewDate`. + - Verifiziert: legit Felder (inkl. der 4) persistieren; injizierte + `id`/`contractNumber`/`cancellationLetterPath` werden ignoriert. + - [x] **🗂️ Neuer Vertragsstatus „Gekündigt / bestätigt" + Kündigungs-Workflow** (2026-08-13) - Bisheriges **„Gekündigt"** umbenannt in **„Gekündigt / Bestätigung abwarten"** (Status `CANCELLED`) – wird jetzt automatisch gesetzt, sobald ein