diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index c025d534..ac429f51 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -429,7 +429,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis export async function getCockpit(req: AuthRequest, res: Response): Promise { try { - const cockpitData = await contractCockpitService.getCockpitData(); + // Portal-User dürfen nur ihre eigenen + vertretene Kunden (mit Vollmacht) sehen. + // Analog zu getContracts. Sonst leakt das Cockpit ALLE Verträge ALLER Kunden + // (Pentest Runde 4, 2026-05-16: HOCH). + let customerIds: number[] | undefined; + if (req.user?.isCustomerPortal && req.user.customerId) { + customerIds = [req.user.customerId]; + const representedIds: number[] = req.user.representedCustomerIds || []; + for (const repCustId of representedIds) { + const hasAuth = await authorizationService.hasAuthorization(repCustId, req.user.customerId); + if (hasAuth) { + customerIds.push(repCustId); + } + } + } + + const cockpitData = await contractCockpitService.getCockpitData({ customerIds }); res.json({ success: true, data: cockpitData } as ApiResponse); } catch (error) { console.error('Cockpit error:', error); diff --git a/backend/src/services/contractCockpit.service.ts b/backend/src/services/contractCockpit.service.ts index b20cc33c..7fc9ed84 100644 --- a/backend/src/services/contractCockpit.service.ts +++ b/backend/src/services/contractCockpit.service.ts @@ -183,7 +183,7 @@ function calculateCancellationDeadline( return end; } -export async function getCockpitData(): Promise { +export async function getCockpitData(opts?: { customerIds?: number[] }): Promise { // Lade Einstellungen const settings = await appSettingService.getAllSettings(); const criticalDays = parseInt(settings.deadlineCriticalDays) || 14; @@ -192,12 +192,19 @@ export async function getCockpitData(): Promise { const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30; const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90; + // Portal-Filter: Wenn customerIds gesetzt sind (Kundenportal-User), beschränken + // wir ALLE Cockpit-Queries auf diese Customer-IDs. Leeres Array → keine Treffer. + const customerScopeFilter = opts?.customerIds + ? { customerId: { in: opts.customerIds } } + : {}; + // Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check) const contracts = await prisma.contract.findMany({ where: { status: { in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'], }, + ...customerScopeFilter, }, include: { customer: { @@ -283,9 +290,9 @@ export async function getCockpitData(): Promise { }, }; - // Consent-Daten batch-laden für alle Kunden + // Consent-Daten batch-laden für alle (erlaubten) Kunden const allConsents = await prisma.customerConsent.findMany({ - where: { status: 'GRANTED' }, + where: { status: 'GRANTED', ...customerScopeFilter }, select: { customerId: true, consentType: true }, }); @@ -300,7 +307,7 @@ export async function getCockpitData(): Promise { // Widerrufene Consents laden const withdrawnConsents = await prisma.customerConsent.findMany({ - where: { status: 'WITHDRAWN' }, + where: { status: 'WITHDRAWN', ...customerScopeFilter }, select: { customerId: true, consentType: true }, }); const withdrawnConsentsMap = new Map>(); @@ -733,10 +740,10 @@ export async function getCockpitData(): Promise { }); // Vertragsunabhängige Ausweis-Warnungen - const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays); + const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds); // Gemeldete Zählerstände (REPORTED Status) - const reportedReadings = await getReportedMeterReadings(); + const reportedReadings = await getReportedMeterReadings(opts?.customerIds); return { contracts: cockpitContracts, @@ -754,7 +761,11 @@ export async function getCockpitData(): Promise { /** * Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig) */ -async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise { +async function getDocumentExpiryAlerts( + criticalDays: number, + warningDays: number, + customerIds?: number[], +): Promise { const now = new Date(); const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000); @@ -762,6 +773,7 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number where: { isActive: true, expiryDate: { lte: inWarningDays }, + ...(customerIds ? { customerId: { in: customerIds } } : {}), }, include: { customer: { @@ -798,9 +810,12 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number /** * Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden */ -async function getReportedMeterReadings(): Promise { +async function getReportedMeterReadings(customerIds?: number[]): Promise { const readings = await prisma.meterReading.findMany({ - where: { status: 'REPORTED' }, + where: { + status: 'REPORTED', + ...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}), + }, include: { meter: { include: { diff --git a/docs/todo.md b/docs/todo.md index d7d7a7b5..4eb9bb06 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🚨 Pentest Runde 4 – HOCH: Cockpit-IDOR (Portal-User sah ALLE Kunden)** + - **Realer Angriff**: Portal-User Max bekam mit seinem Token + `GET /api/contracts/cockpit` → komplette Vertragsliste ALLER + Kunden (Customer-Namen, Vertragsnummern, Statūs). + - **Root Cause**: `contractCockpitService.getCockpitData()` filterte + nicht nach Customer, weil das Cockpit ursprünglich nur für Admins + gedacht war. Die `contracts:read`-Permission haben aber auch + Portal-User → Endpoint war erreichbar. + - **Fix**: Service-Signatur erweitert auf + `getCockpitData({ customerIds? })`. Wenn `customerIds` gesetzt + sind, werden Haupt-Vertrags-Query, Consent-Maps, Ausweis- + Warnungen und gemeldete Zählerstände allesamt auf diese IDs + eingeschränkt. Controller bestimmt `customerIds` analog zu + `getContracts`: bei `isCustomerPortal` → eigene + vertretene + Kunden (nur mit Vollmacht); sonst undefined (= alle). + - **Live-verifiziert**: Admin sieht 17 Verträge (3 Kunden); + Portal-User Customer 1 sieht 12 (nur seine); Portal-User + Customer 3 sieht 3 (nur seine); 0 Leaks. + - [x] **🚨 Pentest Runde 3 – drei Findings gefixt** - **KRITISCH – `POST /api/developer/setup` ohne Auth (Privilege Escalation)**: Endpoint war komplett ohne Authentifizierung