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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 19:27:25 +02:00
co-authored by Claude Opus 4.8
parent fc3131059b
commit 94e4fdee23
2 changed files with 54 additions and 3 deletions
+39 -3
View File
@@ -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<string, unknown> {
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
const out: Record<string, unknown> = {};
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,
+15
View File
@@ -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