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;
|
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) {
|
export async function createContract(data: ContractCreateData) {
|
||||||
const {
|
const {
|
||||||
energyDetails,
|
energyDetails,
|
||||||
@@ -400,7 +431,7 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
contractNumber: generateContractNumber(data.type),
|
contractNumber: generateContractNumber(data.type),
|
||||||
portalPasswordEncrypted,
|
portalPasswordEncrypted,
|
||||||
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
...(energyDetails && ['ELECTRICITY', 'GAS'].includes(data.type)
|
||||||
? { energyDetails: { create: energyDetails } }
|
? { energyDetails: { create: pickEnergyScalars(energyDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
...(internetDetails && ['DSL', 'CABLE', 'FIBER'].includes(data.type)
|
||||||
? {
|
? {
|
||||||
@@ -470,10 +501,10 @@ export async function createContract(data: ContractCreateData) {
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(tvDetails && data.type === 'TV'
|
...(tvDetails && data.type === 'TV'
|
||||||
? { tvDetails: { create: tvDetails } }
|
? { tvDetails: { create: pickTvScalars(tvDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
...(carInsuranceDetails && data.type === 'CAR_INSURANCE'
|
||||||
? { carInsuranceDetails: { create: carInsuranceDetails } }
|
? { carInsuranceDetails: { create: pickCarInsuranceScalars(carInsuranceDetails) as any } }
|
||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -553,8 +584,8 @@ export async function updateContract(
|
|||||||
|
|
||||||
await prisma.energyContractDetails.upsert({
|
await prisma.energyContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: energyDetails,
|
update: pickEnergyScalars(energyDetails) as any,
|
||||||
create: { contractId: id, ...energyDetails },
|
create: { contractId: id, ...(pickEnergyScalars(energyDetails) as any) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
// ContractMeter synchronisieren wenn sich der Zähler ändert
|
||||||
@@ -679,11 +710,12 @@ export async function updateContract(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mobileDetails) {
|
if (mobileDetails) {
|
||||||
const { simCards, ...mobileData } = mobileDetails;
|
const { simCards } = mobileDetails;
|
||||||
// Whitelist auf das Netz anwenden, bevor mobileData per Spread an
|
// Feld-Whitelist (Pentest R162-01): nur Scalars an Prisma – kein injiziertes
|
||||||
// Prisma geht (Update-Pfad reicht sonst beliebige Strings durch).
|
// id/contractId. Danach Netz normalisieren.
|
||||||
|
const mobileData: Record<string, unknown> = pickMobileScalars(mobileDetails);
|
||||||
if ('mobileNetwork' in mobileData) {
|
if ('mobileNetwork' in mobileData) {
|
||||||
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork);
|
mobileData.mobileNetwork = normalizeMobileNetwork(mobileData.mobileNetwork as string | null | undefined);
|
||||||
}
|
}
|
||||||
const existing = await prisma.mobileContractDetails.findUnique({
|
const existing = await prisma.mobileContractDetails.findUnique({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
@@ -692,7 +724,7 @@ export async function updateContract(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.mobileContractDetails.update({
|
await prisma.mobileContractDetails.update({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
data: mobileData,
|
data: mobileData as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (simCards) {
|
if (simCards) {
|
||||||
@@ -730,7 +762,7 @@ export async function updateContract(
|
|||||||
await prisma.mobileContractDetails.create({
|
await prisma.mobileContractDetails.create({
|
||||||
data: {
|
data: {
|
||||||
contractId: id,
|
contractId: id,
|
||||||
...mobileData,
|
...(mobileData as any),
|
||||||
simCards: simCards
|
simCards: simCards
|
||||||
? {
|
? {
|
||||||
create: simCards.map((sc) => ({
|
create: simCards.map((sc) => ({
|
||||||
@@ -753,16 +785,16 @@ export async function updateContract(
|
|||||||
if (tvDetails) {
|
if (tvDetails) {
|
||||||
await prisma.tvContractDetails.upsert({
|
await prisma.tvContractDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: tvDetails,
|
update: pickTvScalars(tvDetails) as any,
|
||||||
create: { contractId: id, ...tvDetails },
|
create: { contractId: id, ...(pickTvScalars(tvDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (carInsuranceDetails) {
|
if (carInsuranceDetails) {
|
||||||
await prisma.carInsuranceDetails.upsert({
|
await prisma.carInsuranceDetails.upsert({
|
||||||
where: { contractId: id },
|
where: { contractId: id },
|
||||||
update: carInsuranceDetails,
|
update: pickCarInsuranceScalars(carInsuranceDetails) as any,
|
||||||
create: { contractId: id, ...carInsuranceDetails },
|
create: { contractId: id, ...(pickCarInsuranceScalars(carInsuranceDetails) as any) },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ 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)
|
- [x] **📋 Aufgaben ohne Kunde/Vertrag anlegbar** (2026-08-18)
|
||||||
- `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint
|
- `ContractTask.contractId` nullable (Migration `20260818110000`). Neuer Endpoint
|
||||||
`POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne
|
`POST /tasks` (staff-only, `contracts:update`) für allgemeine Aufgaben ohne
|
||||||
|
|||||||
Reference in New Issue
Block a user