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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user