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:
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user