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