From 7bdb885a1058e476d0f74eceb81cea76fdc72c3b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Tue, 18 Aug 2026 16:10:45 +0200 Subject: [PATCH] Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01) createContract/updateContract spreadeten energyDetails/tvDetails/ carInsuranceDetails/mobileDetails (via ...mobileData) roh an Prisma. Injizierte id/contractId konnten ein Detail-Objekt auf einen Fremdvertrag reparenten oder den PK frei setzen (stilles 200 statt 400) - MEDIUM (Integritaet; kein Cross-Tenant, staff-only, Portal 403). Fix: Feld-Whitelists (pickEnergyScalars/pickMobileScalars/pickTvScalars/ pickCarInsuranceScalars, analog R158) an allen Spread-Stellen in create+update. internet war bereits explizit (preparedInternetData) - safe. Whitelists programmatisch gegen die DB-Spalten abgeglichen (minus id/contractId/ verschluesselt) - alle Diffs leer. Verifiziert: energyDetails{basePrice:99.99, id:999999, contractId:fremd} -> basePrice aktualisiert, ecd.id + contractId unveraendert (kein Reparenting). Co-Authored-By: Claude Opus 4.8 --- backend/src/services/contract.service.ts | 62 ++++++++++++++++++------ docs/todo.md | 13 +++++ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/backend/src/services/contract.service.ts b/backend/src/services/contract.service.ts index cba52348..7c3babe9 100644 --- a/backend/src/services/contract.service.ts +++ b/backend/src/services/contract.service.ts @@ -378,6 +378,37 @@ function pickContractScalars(data: unknown): Record { return out; } +// Feld-Whitelists für die Nested-Detail-Modelle (Mass-Assignment-Schutz, Pentest +// R162-01). Autoritativ aus den DB-Spalten, jeweils OHNE id/contractId (PK/FK +// nie client-setzbar) und ohne Nested-Relationen (phoneNumbers/simCards/ +// contractMeters) bzw. verschlüsselte Felder (internetPasswordEncrypted), die +// separat behandelt werden. Verhindert Reparenting/Orphaning per injizierter +// id/contractId im energy/mobile/tv/carInsurance-Objekt. +function makePick(fields: readonly string[]) { + return (data: unknown): Record => { + const src = (data && typeof data === 'object') ? (data as Record) : {}; + const out: Record = {}; + for (const key of fields) if (key in src) out[key] = src[key]; + return out; + }; +} + +const pickEnergyScalars = makePick([ + 'meterId', 'annualConsumption', 'basePrice', 'unitPrice', 'previousProviderName', + 'previousCustomerNumber', 'annualConsumptionKwh', 'maloId', 'unitPriceNt', + 'instantBonus', 'newCustomerBonus', 'noBonusDesired', +]); +const pickMobileScalars = makePick([ + 'phoneNumber', 'simCardNumber', 'dataVolume', 'includedMinutes', 'includedSMS', + 'deviceModel', 'deviceImei', 'requiresMultisim', 'mobileNetwork', +]); +const pickTvScalars = makePick(['receiverModel', 'smartcardNumber', 'package']); +const pickCarInsuranceScalars = makePick([ + 'licensePlate', 'hsn', 'tsn', 'vin', 'vehicleType', 'firstRegistration', + 'noClaimsClass', 'insuranceType', 'deductiblePartial', 'deductibleFull', + 'policyNumber', 'previousInsurer', +]); + export async function createContract(data: ContractCreateData) { const { energyDetails, @@ -400,7 +431,7 @@ export async function createContract(data: ContractCreateData) { contractNumber: generateContractNumber(data.type), portalPasswordEncrypted, ...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type) - ? { energyDetails: { create: energyDetails } } + ? { energyDetails: { create: pickEnergyScalars(energyDetails) as any } } : {}), ...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type) ? { @@ -470,10 +501,10 @@ export async function createContract(data: ContractCreateData) { } : {}), ...(tvDetails && data.type === 'TV' - ? { tvDetails: { create: tvDetails } } + ? { tvDetails: { create: pickTvScalars(tvDetails) as any } } : {}), ...(carInsuranceDetails && data.type === 'CAR_INSURANCE' - ? { carInsuranceDetails: { create: carInsuranceDetails } } + ? { carInsuranceDetails: { create: pickCarInsuranceScalars(carInsuranceDetails) as any } } : {}), }, include: { @@ -553,8 +584,8 @@ export async function updateContract( await prisma.energyContractDetails.upsert({ where: { contractId: id }, - update: energyDetails, - create: { contractId: id, ...energyDetails }, + update: pickEnergyScalars(energyDetails) as any, + create: { contractId: id, ...(pickEnergyScalars(energyDetails) as any) }, }); // ContractMeter synchronisieren wenn sich der Zähler ändert @@ -679,11 +710,12 @@ export async function updateContract( } if (mobileDetails) { - const { simCards, ...mobileData } = mobileDetails; - // Whitelist auf das Netz anwenden, bevor mobileData per Spread an - // Prisma geht (Update-Pfad reicht sonst beliebige Strings durch). + const { simCards } = mobileDetails; + // Feld-Whitelist (Pentest R162-01): nur Scalars an Prisma – kein injiziertes + // id/contractId. Danach Netz normalisieren. + const mobileData: Record = pickMobileScalars(mobileDetails); if ('mobileNetwork' in mobileData) { - mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork); + mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork as string | null | undefined); } const existing = await prisma.mobileContractDetails.findUnique({ where: { contractId: id }, @@ -692,7 +724,7 @@ export async function updateContract( if (existing) { await prisma.mobileContractDetails.update({ where: { contractId: id }, - data: mobileData, + data: mobileData as any, }); if (simCards) { @@ -730,7 +762,7 @@ export async function updateContract( await prisma.mobileContractDetails.create({ data: { contractId: id, - ...mobileData, + ...(mobileData as any), simCards: simCards ? { create: simCards.map((sc) => ({ @@ -753,16 +785,16 @@ export async function updateContract( if (tvDetails) { await prisma.tvContractDetails.upsert({ where: { contractId: id }, - update: tvDetails, - create: { contractId: id, ...tvDetails }, + update: pickTvScalars(tvDetails) as any, + create: { contractId: id, ...(pickTvScalars(tvDetails) as any) }, }); } if (carInsuranceDetails) { await prisma.carInsuranceDetails.upsert({ where: { contractId: id }, - update: carInsuranceDetails, - create: { contractId: id, ...carInsuranceDetails }, + update: pickCarInsuranceScalars(carInsuranceDetails) as any, + create: { contractId: id, ...(pickCarInsuranceScalars(carInsuranceDetails) as any) }, }); } diff --git a/docs/todo.md b/docs/todo.md index fc22a445..bcc0f723 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 Mass-Assignment-Schutz: Nested-Vertragsdetails (Pentest R162-01)** (2026-08-18) + - `createContract`/`updateContract` spreadeten `energyDetails`/`tvDetails`/ + `carInsuranceDetails`/`mobileDetails` (via `...mobileData`) roh an Prisma → + injizierte `id`/`contractId` konnten ein Detail-Objekt **reparenten** (auf + Fremdvertrag umhängen) oder den **PK frei setzen** (stilles 200 statt 400). + MEDIUM (Integrität; kein Cross-Tenant, staff-only, Portal 403). + - **Fix:** Feld-Whitelists (`pickEnergyScalars`/`pickMobileScalars`/`pickTvScalars`/ + `pickCarInsuranceScalars`, analog R158) an allen Spread-Stellen. `internet` + war bereits explizit (safe). Whitelists programmatisch gegen die DB-Spalten + abgeglichen (minus id/contractId/verschlüsselt) – alle Diffs leer. + - Verifiziert: `energyDetails:{basePrice:99.99, id:999999, contractId:fremd}` → + basePrice aktualisiert, ecd.id + contractId **unverändert** (kein Reparenting). + - [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18) - `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint `POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne