Security-Hardening Runde 8: Cockpit-IDOR (Portal sah ALLE Kunden)

Pentest Runde 4 – HOCH:
GET /api/contracts/cockpit gab Portal-Usern mit contracts:read
die kompletten Vertrags-, Ausweis- und Zählerstand-Daten ALLER
Kunden zurück. Realer Angriff erfolgreich durchgespielt.

Fix:
contractCockpitService.getCockpitData({ customerIds? }) – wenn
gesetzt, werden ALLE internen Queries (Contract, CustomerConsent
GRANTED/WITHDRAWN, IdentityDocument-Expiry, MeterReading-Reported)
auf diese Customer-IDs eingeschränkt.

Controller getCockpit ermittelt customerIds analog getContracts:
- isCustomerPortal → [eigene, ...vertretene mit Vollmacht]
- sonst (Mitarbeiter/Admin) → undefined (alle Kunden)

Live-verifiziert:
- Admin: 17 Verträge über 3 Kunden (Baseline)
- Portal-User Customer 1: 12 Verträge, alle mit customerId=1
- Portal-User Customer 3: 3 Verträge, alle mit customerId=3
- 0 fremde Verträge in Portal-Responses

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 19:55:38 +02:00
parent a7d12b8540
commit 75c833500e
3 changed files with 59 additions and 10 deletions
+16 -1
View File
@@ -429,7 +429,22 @@ export async function getSipCredentials(req: AuthRequest, res: Response): Promis
export async function getCockpit(req: AuthRequest, res: Response): Promise<void> { export async function getCockpit(req: AuthRequest, res: Response): Promise<void> {
try { 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); res.json({ success: true, data: cockpitData } as ApiResponse);
} catch (error) { } catch (error) {
console.error('Cockpit error:', error); console.error('Cockpit error:', error);
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
return end; return end;
} }
export async function getCockpitData(): Promise<CockpitResult> { export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
// Lade Einstellungen // Lade Einstellungen
const settings = await appSettingService.getAllSettings(); const settings = await appSettingService.getAllSettings();
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14; const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
@@ -192,12 +192,19 @@ export async function getCockpitData(): Promise<CockpitResult> {
const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30; const docExpiryCriticalDays = parseInt(settings.documentExpiryCriticalDays) || 30;
const docExpiryWarningDays = parseInt(settings.documentExpiryWarningDays) || 90; 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) // Lade alle relevanten Verträge (inkl. CANCELLED/DEACTIVATED für Schlussrechnung-Check)
const contracts = await prisma.contract.findMany({ const contracts = await prisma.contract.findMany({
where: { where: {
status: { status: {
in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'], in: ['ACTIVE', 'PENDING', 'DRAFT', 'CANCELLED', 'DEACTIVATED', 'EXPIRED'],
}, },
...customerScopeFilter,
}, },
include: { include: {
customer: { customer: {
@@ -283,9 +290,9 @@ export async function getCockpitData(): Promise<CockpitResult> {
}, },
}; };
// Consent-Daten batch-laden für alle Kunden // Consent-Daten batch-laden für alle (erlaubten) Kunden
const allConsents = await prisma.customerConsent.findMany({ const allConsents = await prisma.customerConsent.findMany({
where: { status: 'GRANTED' }, where: { status: 'GRANTED', ...customerScopeFilter },
select: { customerId: true, consentType: true }, select: { customerId: true, consentType: true },
}); });
@@ -300,7 +307,7 @@ export async function getCockpitData(): Promise<CockpitResult> {
// Widerrufene Consents laden // Widerrufene Consents laden
const withdrawnConsents = await prisma.customerConsent.findMany({ const withdrawnConsents = await prisma.customerConsent.findMany({
where: { status: 'WITHDRAWN' }, where: { status: 'WITHDRAWN', ...customerScopeFilter },
select: { customerId: true, consentType: true }, select: { customerId: true, consentType: true },
}); });
const withdrawnConsentsMap = new Map<number, Set<string>>(); const withdrawnConsentsMap = new Map<number, Set<string>>();
@@ -733,10 +740,10 @@ export async function getCockpitData(): Promise<CockpitResult> {
}); });
// Vertragsunabhängige Ausweis-Warnungen // Vertragsunabhängige Ausweis-Warnungen
const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays); const documentAlerts = await getDocumentExpiryAlerts(docExpiryCriticalDays, docExpiryWarningDays, opts?.customerIds);
// Gemeldete Zählerstände (REPORTED Status) // Gemeldete Zählerstände (REPORTED Status)
const reportedReadings = await getReportedMeterReadings(); const reportedReadings = await getReportedMeterReadings(opts?.customerIds);
return { return {
contracts: cockpitContracts, contracts: cockpitContracts,
@@ -754,7 +761,11 @@ export async function getCockpitData(): Promise<CockpitResult> {
/** /**
* Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig) * Alle aktiven Ausweise die ablaufen oder abgelaufen sind (vertragsunabhängig)
*/ */
async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number): Promise<DocumentAlert[]> { async function getDocumentExpiryAlerts(
criticalDays: number,
warningDays: number,
customerIds?: number[],
): Promise<DocumentAlert[]> {
const now = new Date(); const now = new Date();
const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000); const inWarningDays = new Date(now.getTime() + warningDays * 24 * 60 * 60 * 1000);
@@ -762,6 +773,7 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
where: { where: {
isActive: true, isActive: true,
expiryDate: { lte: inWarningDays }, expiryDate: { lte: inWarningDays },
...(customerIds ? { customerId: { in: customerIds } } : {}),
}, },
include: { include: {
customer: { customer: {
@@ -798,9 +810,12 @@ async function getDocumentExpiryAlerts(criticalDays: number, warningDays: number
/** /**
* Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden * Vom Kunden gemeldete Zählerstände die noch nicht übertragen wurden
*/ */
async function getReportedMeterReadings(): Promise<ReportedMeterReading[]> { async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
const readings = await prisma.meterReading.findMany({ const readings = await prisma.meterReading.findMany({
where: { status: 'REPORTED' }, where: {
status: 'REPORTED',
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
},
include: { include: {
meter: { meter: {
include: { include: {
+19
View File
@@ -97,6 +97,25 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ 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** - [x] **🚨 Pentest Runde 3 drei Findings gefixt**
- **KRITISCH `POST /api/developer/setup` ohne Auth (Privilege - **KRITISCH `POST /api/developer/setup` ohne Auth (Privilege
Escalation)**: Endpoint war komplett ohne Authentifizierung Escalation)**: Endpoint war komplett ohne Authentifizierung