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> {
|
||||
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);
|
||||
|
||||
@@ -183,7 +183,7 @@ function calculateCancellationDeadline(
|
||||
return end;
|
||||
}
|
||||
|
||||
export async function getCockpitData(): Promise<CockpitResult> {
|
||||
export async function getCockpitData(opts?: { customerIds?: number[] }): Promise<CockpitResult> {
|
||||
// Lade Einstellungen
|
||||
const settings = await appSettingService.getAllSettings();
|
||||
const criticalDays = parseInt(settings.deadlineCriticalDays) || 14;
|
||||
@@ -192,12 +192,19 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
||||
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<CockpitResult> {
|
||||
},
|
||||
};
|
||||
|
||||
// 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<CockpitResult> {
|
||||
|
||||
// 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<number, Set<string>>();
|
||||
@@ -733,10 +740,10 @@ export async function getCockpitData(): Promise<CockpitResult> {
|
||||
});
|
||||
|
||||
// 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<CockpitResult> {
|
||||
/**
|
||||
* 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 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<ReportedMeterReading[]> {
|
||||
async function getReportedMeterReadings(customerIds?: number[]): Promise<ReportedMeterReading[]> {
|
||||
const readings = await prisma.meterReading.findMany({
|
||||
where: { status: 'REPORTED' },
|
||||
where: {
|
||||
status: 'REPORTED',
|
||||
...(customerIds ? { meter: { customerId: { in: customerIds } } } : {}),
|
||||
},
|
||||
include: {
|
||||
meter: {
|
||||
include: {
|
||||
|
||||
Reference in New Issue
Block a user