diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 55d34099..bd074f5e 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -124,6 +124,9 @@ function determineSensitivity(resourceType: string): AuditSensitivity { User: 'HIGH', CustomerConsent: 'HIGH', DataDeletionRequest: 'HIGH', + // Abgewehrter IDOR-/Cross-Boundary-Zugriffsversuch (canAccess*-403). + // HIGH, weil ein Treffer auf gezielte Fremddaten-Enumeration hindeutet. + AccessDenied: 'HIGH', // MEDIUM Contract: 'MEDIUM', Address: 'MEDIUM', diff --git a/backend/src/utils/accessControl.ts b/backend/src/utils/accessControl.ts index 1a39d921..810eef83 100644 --- a/backend/src/utils/accessControl.ts +++ b/backend/src/utils/accessControl.ts @@ -12,10 +12,22 @@ import prisma from '../lib/prisma.js'; import * as authorizationService from '../services/authorization.service.js'; import { AuthRequest } from '../types/index.js'; import { emit as emitSecurityEvent, contextFromRequest } from '../services/securityMonitor.service.js'; +import { logChange } from '../services/audit.service.js'; /** * Wird intern aufgerufen, wenn ein canAccess*-Check 403 zurückgibt. - * Schreibt ein SecurityEvent für Monitoring + spätere Threshold-Detection. + * + * Schreibt in ZWEI Streams: + * 1. SecurityEvent (Monitoring): Alerting + Threshold-Detection, aber + * über /api/monitoring/events löschbar und nicht hash-verkettet. + * 2. AuditLog (tamper-evident): hash-verketteter Eintrag, überlebt ein + * Clearen des Monitoring-Streams. Pentest R120 hat aufgedeckt, dass + * der reine SecurityEvent-Stream für Forensik „lautlos genug" wirkt – + * ein Cross-Customer-Zugriffsversuch (IDOR) soll manipulationssicher + * nachweisbar sein. `action: READ`, weil es ein unautorisierter + * Lese-VERSUCH ist (kein eigener Enum-Wert nötig); der distinktive + * resourceType `AccessDenied` macht es filterbar + * (`searchAuditLogs({ resourceType: 'AccessDenied' })`). */ function emitAccessDenied(req: AuthRequest, label: string, targetId: number | string): void { const ctx = contextFromRequest(req); @@ -30,6 +42,16 @@ function emitAccessDenied(req: AuthRequest, label: string, targetId: number | st endpoint: ctx.endpoint, details: { resource: label, targetId }, }); + // Fire-and-forget; logChange fängt eigene Fehler intern ab und darf die + // 403-Response nie blockieren. + void logChange({ + req, + action: 'READ', + resourceType: 'AccessDenied', + resourceId: String(targetId), + label: `IDOR-Zugriffsversuch abgewehrt: ${label} #${targetId}`, + details: { resource: label, targetId, endpoint: ctx.endpoint }, + }); } /** diff --git a/docs/SECURITY-HARDENING.md b/docs/SECURITY-HARDENING.md index b80a637e..f98a7fbf 100644 --- a/docs/SECURITY-HARDENING.md +++ b/docs/SECURITY-HARDENING.md @@ -692,6 +692,19 @@ zunächst gegen den falschen Host getestet wurde `kundencenter-stage.stressfrei-wechseln.de`). Auf dem korrekten Staging-Host reproduzierte sich das Finding sofort. +**Nachtrag – Zwei-Stream-Logging der Abwehr:** Beim Retest fiel auf, dass +der abgewehrte Zugriff zwar im **SecurityEvent**-Monitoring-Stream landet +(`ACCESS_DENIED`, MEDIUM, sichtbar unter +`GET /api/monitoring/events?type=ACCESS_DENIED`), aber **nicht** im +AuditLog – dort hatte der Pentester zuerst gesucht. Der Monitoring-Stream +ist zudem über `DELETE /api/monitoring/events` löschbar und nicht +hash-verkettet. `emitAccessDenied` schreibt daher jetzt **zusätzlich** +einen tamper-evidenten AuditLog-Eintrag (`action: READ`, +`resourceType: 'AccessDenied'`, Sensitivity HIGH), der ein Clearen des +Monitoring-Streams überlebt und über die Hash-Kette manipulationssicher +ist. Gilt für alle `canAccess*`-403 (Contract + Customer, jeweils inkl. +Vollmacht-fehlt-Fall). + --- ## 🔒 Runde 110 – Mass-Assignment-Whitelist auf 7 Katalog-Endpunkten diff --git a/docs/todo.md b/docs/todo.md index 42ec7f2f..9eac8473 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -117,6 +117,14 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung rendert Vertrags-Provider/Tarif-Namen, React escaped sie als Text. Neue Writes werden zusätzlich per `sanitizeContractBody` (stripHtml) entschärft; der Altwert stammt aus DB-Direkteingabe. + - Nachtrag (Retest): Der abgewehrte Zugriff wird nun in ZWEI Streams + protokolliert. Bisher nur `SecurityEvent` (`ACCESS_DENIED`, sichtbar + unter `/api/monitoring/events`, aber löschbar + nicht hash-verkettet) + – der Pentester suchte im AuditLog und fand nichts. `emitAccessDenied` + schreibt jetzt zusätzlich einen tamper-evidenten AuditLog-Eintrag + (`action: READ`, `resourceType: 'AccessDenied'`, Sensitivity HIGH) + für alle `canAccess*`-403. Meine ursprüngliche Formulierung „landet + im Audit" war die falsche Tabelle – jetzt stimmt sie. - [x] **👁 Kundenansicht: Toggle „Deaktivierte Verträge anzeigen"** - Der Vertragsbaum beim Kunden (`CustomerDetail` → Tab Verträge)