Files
opencrm/backend/src/controllers/invoice.controller.ts
T
duffyduck a023e96012 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>
2026-06-01 21:01:06 +02:00

217 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Request, Response } from 'express';
import * as invoiceService from '../services/invoice.service.js';
import { logChange } from '../services/audit.service.js';
import { ApiResponse, AuthRequest } from '../types/index.js';
import { canAccessContract, canAccessEnergyContractDetails } from '../utils/accessControl.js';
/**
* Alle Rechnungen für ein EnergyContractDetails abrufen
*/
export async function getInvoices(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoices = await invoiceService.getInvoices(ecdId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
console.error('getInvoices error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Rechnungen',
} as ApiResponse);
}
}
/**
* Einzelne Rechnung abrufen
*/
export async function getInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const invoice = await invoiceService.getInvoice(ecdId, invoiceId);
if (!invoice) {
res.status(404).json({
success: false,
error: 'Rechnung nicht gefunden',
} as ApiResponse);
return;
}
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
console.error('getInvoice error:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Rechnung',
} as ApiResponse);
}
}
/**
* 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);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
const { invoiceDate, invoiceType, documentPath, 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.addInvoice(ecdId, {
invoiceDate: new Date(invoiceDate),
invoiceType,
documentPath,
notes,
});
await logChange({
req, action: 'CREATE', resourceType: 'Invoice',
resourceId: invoice.id.toString(),
label: `Rechnung (${invoiceType}) hinzugefügt`,
});
res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
console.error('addInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen der Rechnung',
} as ApiResponse);
}
}
/**
* Rechnung aktualisieren
*/
export async function updateInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
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,
invoiceType,
documentPath,
notes,
});
await logChange({
req, action: 'UPDATE', resourceType: 'Invoice',
resourceId: invoiceId.toString(),
label: `Rechnung aktualisiert`,
});
res.json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
console.error('updateInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren der Rechnung',
} as ApiResponse);
}
}
/**
* Rechnung löschen
*/
export async function deleteInvoice(req: AuthRequest, res: Response): Promise<void> {
try {
const ecdId = parseInt(req.params.ecdId);
const invoiceId = parseInt(req.params.invoiceId);
if (!(await canAccessEnergyContractDetails(req, res, ecdId))) return;
await invoiceService.deleteInvoice(ecdId, invoiceId);
await logChange({
req, action: 'DELETE', resourceType: 'Invoice',
resourceId: invoiceId.toString(),
label: `Rechnung gelöscht`,
});
res.json({ success: true, data: null } as ApiResponse);
} catch (error) {
console.error('deleteInvoice error:', error);
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Löschen der Rechnung',
} as ApiResponse);
}
}
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
try {
const contractId = parseInt(req.params.id);
if (!(await canAccessContract(req, res, contractId))) return;
const invoices = await invoiceService.getInvoicesByContract(contractId);
res.json({ success: true, data: invoices } as ApiResponse);
} catch (error) {
res.status(500).json({ success: false, error: 'Fehler beim Laden der Rechnungen' } as ApiResponse);
}
}
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
try {
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,
notes,
});
await logChange({
req, action: 'CREATE', resourceType: 'Invoice',
resourceId: invoice.id.toString(),
label: `Rechnung (${invoiceType}) hinzugefügt`,
});
res.status(201).json({ success: true, data: invoice } as ApiResponse);
} catch (error) {
res.status(400).json({
success: false,
error: error instanceof Error ? error.message : 'Fehler beim Hinzufügen',
} as ApiResponse);
}
}