Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)
Die create/update-Services reichten rohen req.body an Prisma durch, wodurch
customerId (Owner) und id (PK) per Update mutierbar waren - staff-only, kein
Cross-Tenant-Bruch, aber echte Integritaetsschwaeche; mit dem neuen
cardNumber-Feld liegt zudem Finanz-PII auf dieser Flaeche.
Fix: explizite Feld-Whitelist im Service (create+update) fuer BankCard,
Address und IdentityDocument - nur benannte Spalten gehen an Prisma, kein
...data/req.body-Spread mehr. Controller-Helper pickBankCardFields haelt
zusaetzlich die Audit-Logs sauber (keine Phantom-Eintraege injizierter Keys).
Verifiziert: updateBankCard mit {customerId:99999, id:88888, bogusField, ...}
-> id+customerId unveraendert, nur cardNumber gesetzt, Fremdfelder ignoriert.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,23 @@ import {
|
|||||||
} from '../utils/accessControl.js';
|
} from '../utils/accessControl.js';
|
||||||
import { ApiError } from '../utils/apiError.js';
|
import { ApiError } from '../utils/apiError.js';
|
||||||
|
|
||||||
|
// Mass-Assignment-Schutz für Bankkarten (Pentest R155): nur diese Felder dürfen
|
||||||
|
// aus dem Request-Body übernommen werden – NICHT customerId/id/createdAt/… Nur
|
||||||
|
// tatsächlich gesendete Keys landen im Ergebnis, damit Partial-Updates und das
|
||||||
|
// Änderungs-Audit korrekt bleiben.
|
||||||
|
const BANK_CARD_ALLOWED_FIELDS = [
|
||||||
|
'accountHolder', 'iban', 'cardNumber', 'bic', 'bankName', 'expiryDate', 'description', 'isActive',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function pickBankCardFields(body: unknown): Record<string, unknown> {
|
||||||
|
const src = (body && typeof body === 'object') ? (body as Record<string, unknown>) : {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const key of BANK_CARD_ALLOWED_FIELDS) {
|
||||||
|
if (key in src) out[key] = src[key];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
// Customer CRUD
|
// Customer CRUD
|
||||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -401,7 +418,8 @@ export async function createBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const customerId = parseInt(req.params.customerId);
|
const customerId = parseInt(req.params.customerId);
|
||||||
if (!(await canAccessCustomer(req, res, customerId))) return;
|
if (!(await canAccessCustomer(req, res, customerId))) return;
|
||||||
const card = await customerService.createBankCard(customerId, req.body);
|
const data = pickBankCardFields(req.body);
|
||||||
|
const card = await customerService.createBankCard(customerId, data as any);
|
||||||
await logChange({
|
await logChange({
|
||||||
req, action: 'CREATE', resourceType: 'BankCard',
|
req, action: 'CREATE', resourceType: 'BankCard',
|
||||||
resourceId: card.id.toString(),
|
resourceId: card.id.toString(),
|
||||||
@@ -421,7 +439,10 @@ export async function updateBankCard(req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
const cardId = parseInt(req.params.id);
|
const cardId = parseInt(req.params.id);
|
||||||
if (!(await canAccessBankCard(req, res, cardId))) return;
|
if (!(await canAccessBankCard(req, res, cardId))) return;
|
||||||
const data = req.body;
|
// Mass-Assignment-Schutz (Pentest R155): nur erlaubte Felder aus dem Body,
|
||||||
|
// kein customerId/id/Fremdfeld. Das Audit unten läuft über diese saubere
|
||||||
|
// Menge (keine Phantom-Einträge). Enforcement zusätzlich im Service.
|
||||||
|
const data = pickBankCardFields(req.body);
|
||||||
|
|
||||||
// Vorherigen Stand laden für Audit
|
// Vorherigen Stand laden für Audit
|
||||||
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
const before = await prisma.bankCard.findUnique({ where: { id: cardId } });
|
||||||
|
|||||||
@@ -238,10 +238,17 @@ export async function createAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.address.create({
|
return prisma.address.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
type: data.type,
|
||||||
|
street: data.street,
|
||||||
|
houseNumber: data.houseNumber,
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country,
|
||||||
|
isDefault: data.isDefault,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -272,9 +279,18 @@ export async function updateAddress(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.address.update({
|
return prisma.address.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
street: data.street,
|
||||||
|
houseNumber: data.houseNumber,
|
||||||
|
postalCode: data.postalCode,
|
||||||
|
city: data.city,
|
||||||
|
country: data.country,
|
||||||
|
isDefault: data.isDefault,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +325,18 @@ export async function createBankCard(
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `...data`-Spread: verhindert Mass-Assignment
|
||||||
|
// (Pentest R155). Sonst könnte roher req.body u.a. `customerId`/`id` setzen.
|
||||||
return prisma.bankCard.create({
|
return prisma.bankCard.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -331,9 +355,21 @@ export async function updateBankCard(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist statt `data`-Passthrough: verhindert Mass-
|
||||||
|
// Assignment (Pentest R155 – roher req.body konnte `customerId`/`id`
|
||||||
|
// mutieren). Undefined-Felder ignoriert Prisma → Partial-Update bleibt.
|
||||||
return prisma.bankCard.update({
|
return prisma.bankCard.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
accountHolder: data.accountHolder,
|
||||||
|
iban: data.iban,
|
||||||
|
cardNumber: data.cardNumber,
|
||||||
|
bic: data.bic,
|
||||||
|
bankName: data.bankName,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
description: data.description,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,10 +409,17 @@ export async function createDocument(
|
|||||||
licenseIssueDate?: Date;
|
licenseIssueDate?: Date;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.create({
|
return prisma.identityDocument.create({
|
||||||
data: {
|
data: {
|
||||||
customerId,
|
customerId,
|
||||||
...data,
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -395,9 +438,19 @@ export async function updateDocument(
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
// Explizite Feld-Whitelist (Mass-Assignment-Schutz, Pentest R155).
|
||||||
return prisma.identityDocument.update({
|
return prisma.identityDocument.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data: {
|
||||||
|
type: data.type,
|
||||||
|
documentNumber: data.documentNumber,
|
||||||
|
issuingAuthority: data.issuingAuthority,
|
||||||
|
issueDate: data.issueDate,
|
||||||
|
expiryDate: data.expiryDate,
|
||||||
|
licenseClasses: data.licenseClasses,
|
||||||
|
licenseIssueDate: data.licenseIssueDate,
|
||||||
|
isActive: data.isActive,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
|
|
||||||
## ✅ Erledigt
|
## ✅ Erledigt
|
||||||
|
|
||||||
|
- [x] **🔒 Mass-Assignment-Schutz: Bankkarte/Adresse/Ausweis (Pentest R155)** (2026-08-13)
|
||||||
|
- Controller reichten rohen `req.body` an Prisma durch → `customerId` (Owner)
|
||||||
|
und `id` (PK) waren per Update mutierbar (staff-only, kein Cross-Tenant, aber
|
||||||
|
Integritätsschwäche – und mit `cardNumber` liegt Finanz-PII drauf).
|
||||||
|
- **Fix:** explizite Feld-Whitelist im Service (create+update) für **BankCard,
|
||||||
|
Address, IdentityDocument** – nur benannte Felder gehen an Prisma, kein
|
||||||
|
`...data`/`req.body`-Spread mehr. Zusätzlich Controller-`pickBankCardFields`
|
||||||
|
für saubere Audit-Logs (keine Phantom-Einträge injizierter Keys).
|
||||||
|
- Verifiziert: Update mit `{customerId:99999, id:88888, bogusField, cardNumber}`
|
||||||
|
→ id+customerId **unverändert**, nur cardNumber gesetzt, Fremdfelder ignoriert.
|
||||||
|
|
||||||
- [x] **🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13)
|
- [x] **🪪 Bankkarte-/Ausweis-Details in Vertrag (Ansicht + Bearbeiten) + Kartennummer** (2026-08-13)
|
||||||
- **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration
|
- **Schema:** neues Feld `BankCard.cardNumber` (String?, optional). Migration
|
||||||
`20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev
|
`20260813100000_bank_card_number` (`ADD COLUMN IF NOT EXISTS`), auf Dev
|
||||||
|
|||||||
Reference in New Issue
Block a user