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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 16:10:45 +02:00
co-authored by Claude Opus 4.8
parent 8964820542
commit 7bdb885a10
2 changed files with 60 additions and 15 deletions
+47 -15
View File
@@ -378,6 +378,37 @@ function pickContractScalars(data: unknown): Record<string, unknown> {
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<string, unknown> => {
const src = (data && typeof data === 'object') ? (data as Record<string, unknown>) : {};
const out: Record<string, unknown> = {};
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<string, unknown> = 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) },
});
}
+13
View File
@@ -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