Pentest 56.1/56.2/56.3/56.4/56.5: Ownership-Checks + InvoiceType-Validierung

56.1 HIGH (IDOR auf Upload-Endpoints):
- /upload/bank-cards/:id (POST/DELETE): canAccessBankCard +
  Existenz-Check, multer-Datei wird bei Reject sauber aufgeräumt.
- /upload/documents/:id (POST/DELETE): canAccessIdentityDocument
  + Existenz-Check + Cleanup.
- /upload/customers/:id/{business-registration,commercial-register,
  privacy-policy} (POST/DELETE): canAccessCustomer + Cleanup.
- /upload/invoices/:id (POST/DELETE): canAccessContract über
  Invoice→Contract-Resolve + Cleanup.

56.2 HIGH (IDOR + Consent-Eskalation bei privacy-policy):
- Vor dem upsert auf alle 4 CustomerConsent-Einträge (=GRANTED)
  läuft jetzt canAccessCustomer. Portal-Vertreter ohne Vollmacht
  oder Mitarbeiter mit anderer Customer-Beschränkung kommen
  damit nicht mehr durch.

56.3 LATENT (updateContract / deleteContract):
- Defense-in-Depth: canAccessContract jetzt explizit im Controller,
  nicht nur über die Route-Permission.

56.4 MEDIUM (invoiceType ungeprüft in addInvoiceByContract):
- Neuer assertValidInvoiceType-Helper mit Whitelist
  ['INTERIM','FINAL','NOT_AVAILABLE'] in addInvoice,
  updateInvoice und addInvoiceByContract. updateInvoice nur
  bei explizit gesetztem Wert; addInvoiceByContract zusätzlich
  die fehlende Required-Field-Validierung ergänzt.

56.5 LOW (GDPR-Löschanfragen ohne Ownership-Check):
- POST /api/gdpr/deletions liest customerId jetzt aus dem Body
  (Route hat kein :id-Segment), validiert auf positive Zahl und
  ruft canAccessCustomer auf, bevor die Löschanfrage erstellt wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 21:01:06 +02:00
parent 72de2f00f3
commit a023e96012
4 changed files with 140 additions and 7 deletions
@@ -181,6 +181,11 @@ export async function createContract(req: AuthRequest, res: Response): Promise<v
export async function updateContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent, 2026-06-01): Defense-in-Depth
// canAccessContract explizit aufrufen, statt sich nur auf die
// Route-Permission zu verlassen. Portal-User mit kompromittierter
// Token-Permission würden sonst beliebige Verträge editieren können.
if (!(await canAccessContract(req, res, contractId))) return;
// Vorherigen Stand laden für Audit-Vergleich
const before = await prisma.contract.findUnique({
where: { id: contractId },
@@ -264,6 +269,8 @@ export async function updateContract(req: AuthRequest, res: Response): Promise<v
export async function deleteContract(req: Request, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
// Pentest 56.3 (latent): Defense-in-Depth Ownership-Check vor Delete.
if (!(await canAccessContract(req as AuthRequest, res, contractId))) return;
const contract = await prisma.contract.findUnique({ where: { id: contractId }, select: { contractNumber: true, customerId: true } });
await contractService.deleteContract(contractId);
await logChange({
+14 -1
View File
@@ -65,7 +65,20 @@ export async function exportCustomerData(req: AuthRequest, res: Response) {
*/
export async function createDeletionRequest(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.id);
// Pentest 56.5 (LOW, 2026-06-01): customerId muss als gültige Zahl
// aus dem Body kommen (Route hat kein :id-Segment) und der Caller
// braucht Zugriff auf den Kunden. Ohne den Check konnte jemand mit
// gdpr:delete-Permission Löschanfragen für beliebige Kunden stellen
// (Insider-Sabotage durch Portal-Vertreter ohne Vollmacht).
const bodyCustomerId = req.body?.customerId;
const customerId = typeof bodyCustomerId === 'number'
? bodyCustomerId
: parseInt(bodyCustomerId);
if (!Number.isFinite(customerId) || customerId < 1) {
res.status(400).json({ success: false, error: 'customerId fehlt oder ungültig' });
return;
}
if (!(await canAccessCustomer(req, res, customerId))) return;
const { requestSource } = req.body;
const request = await gdprService.createDeletionRequest({
@@ -53,6 +53,23 @@ export async function getInvoice(req: AuthRequest, res: Response): Promise<void>
/**
* Neue Rechnung hinzufügen
*/
// Pentest 56.4 (MEDIUM, 2026-06-01): invoiceType wurde an manchen
// Endpunkten nicht gegen die Enum-Whitelist validiert; ein beliebiger
// String landete als invoiceType in der DB und konnte Frontend-
// Filter/Reports verwirren oder XSS in Audit-Labels einschleusen.
type ValidInvoiceType = 'INTERIM' | 'FINAL' | 'NOT_AVAILABLE';
const VALID_INVOICE_TYPES: Set<ValidInvoiceType> = new Set(['INTERIM', 'FINAL', 'NOT_AVAILABLE']);
function assertValidInvoiceType(value: unknown, res: Response): value is ValidInvoiceType {
if (typeof value !== 'string' || !VALID_INVOICE_TYPES.has(value as ValidInvoiceType)) {
res.status(400).json({
success: false,
error: `Ungültiger Rechnungstyp. Erlaubt: ${[...VALID_INVOICE_TYPES].join(', ')}.`,
} as ApiResponse);
return false;
}
return true;
}
export async function addInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
@@ -66,6 +83,7 @@ export async function addInvoice(req: AuthRequest, res: Response): Promise<void>
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoice(ecdId, {
invoiceDate: new Date(invoiceDate),
@@ -99,6 +117,8 @@ export async function updateInvoice(req: AuthRequest, res: Response): Promise<vo
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, notes } = req.body;
// 56.4: invoiceType ist beim Update optional nur prüfen wenn gesetzt.
if (invoiceType !== undefined && !assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.updateInvoice(ecdId, invoiceId, {
invoiceDate: invoiceDate ? new Date(invoiceDate) : undefined,
@@ -168,6 +188,14 @@ export async function addInvoiceByContract(req: AuthRequest, res: Response): Pro
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const { invoiceDate, invoiceType, notes } = req.body;
if (!invoiceDate || !invoiceType) {
res.status(400).json({
success: false,
error: 'invoiceDate und invoiceType sind erforderlich',
} as ApiResponse);
return;
}
if (!assertValidInvoiceType(invoiceType, res)) return;
const invoice = await invoiceService.addInvoiceByContract(contractId, {
invoiceDate: new Date(invoiceDate),
invoiceType,