Files
opencrm/backend/src/middleware/audit.ts
T
duffyduckandClaude Opus 4.8 d599eb3702 Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)
Fehlgeschlagener /auth/refresh (Replay/Brute-Force auf geraubte
Refresh-Tokens) wurde als TOKEN_REFRESH/LOW geloggt und entging der
Alarmierung - ein Angreifer konnte von /login auf /refresh ausweichen,
um unter der LOGIN_FAILED-Schwelle zu bleiben.

Audit-Actions speisen die Alert-Engine nicht (die zaehlt SecurityEvent
via emit). Fix daher an zwei Ebenen:

- Detection: refresh()-Catch emittiert TOKEN_REJECTED -> greift die
  bestehende Schwelle (>=3 TOKEN_REJECTED/5min/IP -> CRITICAL). Severity
  wie Access-Token: abgelaufen/revoked = LOW (kein Sofort-Alert),
  ungueltige Signatur/Manipulation = HIGH. auth.service reicht dafuer
  err.code REFRESH_EXPIRED/REFRESH_INVALID durch. "Kein Cookie" emittiert
  bewusst nicht (normaler Erstbesuch).
- Audit-Triage: fehlgeschlagener Refresh -> Sensitivitaet HIGH statt LOW
  + Label "Token-Refresh abgelehnt". Action bleibt TOKEN_REFRESH
  (semantisch ein Refresh, kein Login).

Verifiziert: tsx-Test abgelaufen->LOW, manipuliert/garbage->HIGH; tsc gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 18:20:21 +02:00

481 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Response, NextFunction } from 'express';
import { AuditAction } from '@prisma/client';
import { AuthRequest } from '../types/index.js';
import { createAuditLog } from '../services/audit.service.js';
import { getAuditContext, AuditContext } from './auditContext.js';
// Resource-Typ-Mapping basierend auf Route-Patterns
const RESOURCE_MAPPING: Record<string, { type: string; extractId?: (req: AuthRequest) => string | undefined }> = {
'/api/customers': { type: 'Customer', extractId: (req) => req.params.id || req.params.customerId },
'/api/customers/*/bank-cards': { type: 'BankCard', extractId: (req) => req.params.bankCardId },
'/api/customers/*/documents': { type: 'IdentityDocument', extractId: (req) => req.params.documentId },
'/api/customers/*/addresses': { type: 'Address', extractId: (req) => req.params.addressId },
'/api/customers/*/meters': { type: 'Meter', extractId: (req) => req.params.meterId },
'/api/customers/*/consents': { type: 'CustomerConsent', extractId: (req) => req.params.type },
'/api/contracts': { type: 'Contract', extractId: (req) => req.params.id },
'/api/contracts/*/history': { type: 'ContractHistoryEntry', extractId: (req) => req.params.entryId },
'/api/contracts/*/tasks': { type: 'ContractTask', extractId: (req) => req.params.taskId },
'/api/users': { type: 'User', extractId: (req) => req.params.id },
'/api/providers': { type: 'Provider', extractId: (req) => req.params.id },
'/api/tariffs': { type: 'Tariff', extractId: (req) => req.params.id },
'/api/platforms': { type: 'SalesPlatform', extractId: (req) => req.params.id },
'/api/contract-categories': { type: 'ContractCategory', extractId: (req) => req.params.id },
'/api/cancellation-periods': { type: 'CancellationPeriod', extractId: (req) => req.params.id },
'/api/contract-durations': { type: 'ContractDuration', extractId: (req) => req.params.id },
'/api/settings': { type: 'AppSetting', extractId: (req) => req.params.key },
'/api/email-providers': { type: 'EmailProviderConfig', extractId: (req) => req.params.id },
'/api/meters': { type: 'Meter', extractId: (req) => req.params.id || req.params.meterId },
'/api/upload': { type: 'Upload' },
'/api/email-logs': { type: 'EmailLog' },
'/api/auth': { type: 'Authentication' },
'/api/audit-logs': { type: 'AuditLog', extractId: (req) => req.params.id },
'/api/gdpr': { type: 'GDPR' },
};
// Routen die nicht geloggt werden sollen
const EXCLUDED_ROUTES = [
'/api/health',
'/api/uploads',
];
/**
* Bestimmt die Aktion basierend auf HTTP-Methode und Erfolg
*/
function determineAction(method: string, path: string, success: boolean): AuditAction {
// Spezielle Auth-Aktionen
if (path.includes('/auth/login')) {
return success ? 'LOGIN' : 'LOGIN_FAILED';
}
if (path.includes('/auth/logout')) {
return 'LOGOUT';
}
// Stiller Token-Refresh (Cookie) kein interaktiver Login, eigene Action.
// Erfolg vs. Fehlschlag wird NICHT über die Action getrennt (semantisch beides
// ein Refresh), sondern downstream über success-Flag + Sensitivität (LOW/HIGH)
// und den TOKEN_REJECTED-SecurityEvent im Controller (Pentest R164-01).
if (path.includes('/auth/refresh')) {
return 'TOKEN_REFRESH';
}
// Standard CRUD-Aktionen
switch (method.toUpperCase()) {
case 'GET':
return 'READ';
case 'POST':
return 'CREATE';
case 'PUT':
case 'PATCH':
return 'UPDATE';
case 'DELETE':
return 'DELETE';
default:
return 'READ';
}
}
/**
* Findet den passenden Resource-Typ für einen Pfad
*/
function findResourceMapping(path: string): { type: string; extractId?: (req: AuthRequest) => string | undefined } | null {
// Exakte Matches zuerst prüfen
for (const [pattern, mapping] of Object.entries(RESOURCE_MAPPING)) {
// Konvertiere Pattern zu Regex
const regexPattern = pattern
.replace(/\*/g, '[^/]+')
.replace(/\//g, '\\/');
const regex = new RegExp(`^${regexPattern}(?:/|$)`);
if (regex.test(path)) {
return mapping;
}
}
return null;
}
/**
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
*/
function extractDataSubjectId(req: AuthRequest): number | undefined {
// Aus Route-Parameter
const customerId = req.params.customerId || req.params.id;
if (customerId && req.path.includes('/customers')) {
return parseInt(customerId);
}
// Aus Request-Body (bei Create)
if (req.body?.customerId) {
return parseInt(req.body.customerId);
}
// Bei Kundenportal-Zugriff
if (req.user?.customerId) {
return req.user.customerId;
}
return undefined;
}
/**
* Extrahiert die IP-Adresse des Clients
*/
function getClientIp(req: AuthRequest): string {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
return req.socket.remoteAddress || 'unknown';
}
// Menschenlesbare Bezeichnungen für Resource-Typen
const RESOURCE_TYPE_LABELS: Record<string, string> = {
Customer: 'Kunde',
Contract: 'Vertrag',
BankCard: 'Bankverbindung',
IdentityDocument: 'Ausweis',
Address: 'Adresse',
Meter: 'Zähler',
MeterReading: 'Zählerstand',
User: 'Benutzer',
Provider: 'Anbieter',
Tariff: 'Tarif',
SalesPlatform: 'Vertriebsplattform',
ContractCategory: 'Vertragskategorie',
CancellationPeriod: 'Kündigungsfrist',
ContractDuration: 'Vertragslaufzeit',
EmailProviderConfig: 'E-Mail-Provider',
AppSetting: 'Einstellung',
CustomerConsent: 'Einwilligung',
ContractTask: 'Aufgabe',
ContractHistoryEntry: 'Vertragshistorie',
GDPR: 'Datenschutz',
Authentication: 'Anmeldung',
AuditLog: 'Audit-Protokoll',
StressfreiEmail: 'Stressfrei-E-Mail',
CachedEmail: 'E-Mail',
};
const ACTION_LABELS: Record<string, string> = {
CREATE: 'erstellt',
READ: 'aufgerufen',
UPDATE: 'aktualisiert',
DELETE: 'gelöscht',
EXPORT: 'exportiert',
ANONYMIZE: 'anonymisiert',
LOGIN: 'angemeldet',
LOGOUT: 'abgemeldet',
LOGIN_FAILED: 'Anmeldung fehlgeschlagen',
TOKEN_REFRESH: 'Sitzung verlängert',
};
/**
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
*/
function generateHumanLabel(
action: AuditAction,
resourceType: string,
req: AuthRequest,
responseBody: unknown
): string {
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
const actionName = ACTION_LABELS[action] || action;
// Identifikator aus Response oder Request extrahieren
let identifier = '';
if (responseBody && typeof responseBody === 'object' && 'data' in responseBody) {
const data = (responseBody as { data: Record<string, unknown> }).data;
if (data) {
identifier =
(data.contractNumber as string) ||
(data.customerNumber as string) ||
(data.meterNumber as string) ||
(data.name as string) ||
(data.email as string) ||
(data.firstName && data.lastName ? `${data.firstName} ${data.lastName}` : '') ||
'';
}
}
// Spezial-Labels für bestimmte Endpunkte
const path = req.path;
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
const email = req.body?.email || '';
return action === 'LOGIN'
? `Benutzer ${email} hat sich angemeldet`
: `Anmeldung fehlgeschlagen für ${email}`;
}
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
if (path.includes('/auth/refresh')) {
const failed = responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false;
return failed ? 'Token-Refresh abgelehnt (ungültig/abgelaufen)' : 'Sitzung verlängert (Token erneuert)';
}
// Kunden-Operationen
if (resourceType === 'Customer') {
if (action === 'CREATE') return `Kunde ${identifier} angelegt`;
if (action === 'UPDATE') return `Kundendaten ${identifier} aktualisiert`;
if (action === 'DELETE') return `Kunde ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Kundendaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Kundenliste aufgerufen';
}
// Verträge
if (resourceType === 'Contract') {
if (path.includes('/cockpit')) return 'Vertrags-Cockpit aufgerufen';
if (path.includes('/follow-up')) return `Folgevertrag für ${identifier} erstellt`;
if (path.includes('/snooze')) return `Vertrag ${identifier} zurückgestellt`;
if (path.includes('/password')) return `Passwort für Vertrag ${identifier} abgerufen`;
if (path.includes('/sip-credentials')) return 'SIP-Zugangsdaten abgerufen';
if (path.includes('/internet-credentials')) return `Internet-Zugangsdaten für Vertrag ${identifier} abgerufen`;
if (path.includes('/successor-meter')) return `Folgezähler zu Vertrag ${identifier} hinzugefügt`;
if (action === 'CREATE') return `Vertrag ${identifier} angelegt`;
if (action === 'UPDATE') return `Vertrag ${identifier} aktualisiert`;
if (action === 'DELETE') return `Vertrag ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Vertrag ${identifier} aufgerufen`;
if (action === 'READ') return 'Vertragsliste aufgerufen';
}
// Bankverbindungen
if (resourceType === 'BankCard') {
if (action === 'CREATE') return `Bankverbindung hinzugefügt`;
if (action === 'UPDATE') return `Bankverbindung aktualisiert`;
if (action === 'DELETE') return `Bankverbindung gelöscht`;
}
// Ausweise
if (resourceType === 'IdentityDocument') {
if (action === 'CREATE') return `Ausweis ${identifier} hinzugefügt`;
if (action === 'UPDATE') return `Ausweis ${identifier} aktualisiert`;
if (action === 'DELETE') return `Ausweis gelöscht`;
}
// Adressen
if (resourceType === 'Address') {
if (action === 'CREATE') return `Adresse hinzugefügt`;
if (action === 'UPDATE') return `Adresse aktualisiert`;
if (action === 'DELETE') return `Adresse gelöscht`;
}
// Zähler
if (resourceType === 'Meter') {
if (action === 'CREATE') return `Zähler ${identifier} angelegt`;
if (action === 'UPDATE') return `Zähler ${identifier} aktualisiert`;
if (action === 'DELETE') return `Zähler gelöscht`;
}
// Einwilligungen
if (resourceType === 'CustomerConsent') {
const consentType = req.params.consentType || '';
const consentLabels: Record<string, string> = {
DATA_PROCESSING: 'Datenverarbeitung',
MARKETING_EMAIL: 'E-Mail-Marketing',
MARKETING_PHONE: 'Telefonmarketing',
DATA_SHARING_PARTNER: 'Datenweitergabe',
};
const consentName = consentLabels[consentType] || consentType;
if (action === 'UPDATE') {
const status = req.body?.status;
return status === 'GRANTED'
? `Einwilligung "${consentName}" erteilt`
: `Einwilligung "${consentName}" widerrufen`;
}
if (action === 'READ') return 'Einwilligungen abgerufen';
}
// Benutzer
if (resourceType === 'User') {
if (action === 'CREATE') return `Benutzer ${identifier} angelegt`;
if (action === 'UPDATE') return `Benutzer ${identifier} aktualisiert`;
if (action === 'DELETE') return `Benutzer ${identifier} gelöscht`;
if (action === 'READ' && req.params.id) return `Benutzerdaten ${identifier} aufgerufen`;
if (action === 'READ') return 'Benutzerliste aufgerufen';
}
// Aufgaben
if (resourceType === 'ContractTask') {
if (path.includes('/complete')) return `Aufgabe als erledigt markiert`;
if (action === 'CREATE') return `Aufgabe erstellt`;
if (action === 'UPDATE') return `Aufgabe aktualisiert`;
if (action === 'DELETE') return `Aufgabe gelöscht`;
}
// E-Mail-Provider
if (resourceType === 'EmailProviderConfig') {
if (path.includes('/test-connection')) return `E-Mail-Provider Verbindungstest`;
if (path.includes('/provision')) return `E-Mail-Adresse provisioniert`;
if (action === 'CREATE') return `E-Mail-Provider ${identifier} angelegt`;
if (action === 'UPDATE') return `E-Mail-Provider ${identifier} aktualisiert`;
if (action === 'DELETE') return `E-Mail-Provider ${identifier} gelöscht`;
}
// GDPR
if (resourceType === 'GDPR') {
if (path.includes('/dashboard')) return 'DSGVO-Dashboard aufgerufen';
if (path.includes('/export')) return 'Kundendaten exportiert (DSGVO Art. 15)';
if (path.includes('/privacy-policy')) {
return action === 'UPDATE' ? 'Datenschutzerklärung aktualisiert' : 'Datenschutzerklärung aufgerufen';
}
if (path.includes('/authorization-template')) {
return action === 'UPDATE' ? 'Vollmacht-Vorlage aktualisiert' : 'Vollmacht-Vorlage aufgerufen';
}
if (path.includes('/send-consent-link')) return 'Datenschutz-Link versendet';
if (path.includes('/authorizations') && path.includes('/send')) return 'Vollmacht-Anfrage versendet';
if (path.includes('/authorizations') && path.includes('/grant')) return 'Vollmacht erteilt';
if (path.includes('/authorizations') && path.includes('/withdraw')) return 'Vollmacht widerrufen';
if (path.includes('/authorizations') && path.includes('/upload')) return 'Vollmacht-PDF hochgeladen';
if (path.includes('/authorizations') && path.includes('/document') && action === 'DELETE') return 'Vollmacht-PDF gelöscht';
if (path.includes('/my-privacy')) return 'Eigene Datenschutzseite aufgerufen';
if (path.includes('/my-consent-status')) return 'Eigener Einwilligungsstatus geprüft';
if (path.includes('/my-authorizations')) return 'Eigene Vollmachten aufgerufen';
if (path.includes('/deletions')) {
if (action === 'CREATE') return 'Löschanfrage erstellt';
if (path.includes('/process')) return 'Löschanfrage bearbeitet';
return 'Löschanfragen aufgerufen';
}
if (path.includes('/consent-status')) return 'Einwilligungsstatus geprüft';
if (path.includes('/consents/overview')) return 'Einwilligungsübersicht aufgerufen';
}
// Einstellungen
if (resourceType === 'AppSetting') {
if (action === 'UPDATE') return `Einstellung "${req.params.key || ''}" geändert`;
if (action === 'READ') return 'Einstellungen aufgerufen';
}
// Zähler-Readings
if (path.includes('/readings')) {
if (path.includes('/report')) return 'Zählerstand vom Kunden gemeldet';
if (path.includes('/transfer')) return 'Zählerstand als übertragen markiert';
if (action === 'CREATE') return 'Zählerstand erfasst';
if (action === 'UPDATE') return 'Zählerstand aktualisiert';
if (action === 'DELETE') return 'Zählerstand gelöscht';
}
// Upload-Operationen
if (path.includes('/upload') || path.includes('/privacy-policy')) {
if (path.includes('/privacy-policy') && action === 'DELETE') return 'Datenschutzerklärung-PDF gelöscht';
if (path.includes('/privacy-policy')) return 'Datenschutzerklärung-PDF hochgeladen';
}
// Standard-Fallback
if (identifier) {
return `${typeName} ${identifier} ${actionName}`;
}
return `${typeName} ${actionName}`;
}
/**
* Audit Middleware - loggt alle API-Aufrufe asynchron
*/
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const startTime = Date.now();
// Ausgeschlossene Routen überspringen
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
next();
return;
}
// Resource-Mapping finden
const mapping = findResourceMapping(req.path);
if (!mapping) {
// Unbekannte Route - trotzdem loggen mit generischem Typ
next();
return;
}
// Original res.json überschreiben um Response zu capturen
const originalJson = res.json.bind(res);
let responseBody: unknown = null;
let responseSuccess = true;
res.json = function (body: unknown) {
responseBody = body;
if (typeof body === 'object' && body !== null && 'success' in body) {
responseSuccess = (body as { success: boolean }).success;
}
return originalJson(body);
};
// Response-Ende abfangen für Logging
// Audit-Kontext hier erfassen (bevor AsyncLocalStorage den Kontext verliert)
let capturedAuditContext: ReturnType<typeof getAuditContext> | undefined;
const origEnd = res.end;
(res as any).end = function(chunk?: any, encoding?: any, cb?: any) {
// Kontext VOR dem Ende erfassen
capturedAuditContext = getAuditContext();
return origEnd.call(this, chunk, encoding, cb);
};
res.on('finish', () => {
// Async Logging - blockiert nicht die Response
setImmediate(async () => {
try {
const durationMs = Date.now() - startTime;
const action = determineAction(req.method, req.path, responseSuccess);
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
if (action === 'READ') return;
// Routen die bereits gezielt via logChange() geloggt werden → nicht doppelt loggen
const manuallyLoggedPaths = [
'/api/customers',
'/api/contracts',
'/api/meters',
'/api/gdpr',
'/api/upload',
];
// Login/Logout immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
}
const resourceId = mapping.extractId?.(req);
const dataSubjectId = extractDataSubjectId(req);
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
const auditContext = capturedAuditContext;
// Menschenlesbares Label generieren
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
await createAuditLog({
userId: req.user?.userId,
userEmail: req.user?.email || 'anonymous',
userRole: req.user?.isCustomerPortal ? 'Kundenportal' : (req.user as any)?.roleName || 'Mitarbeiter',
customerId: req.user?.customerId,
isCustomerPortal: req.user?.isCustomerPortal,
action,
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
// Fehlgeschlagener Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in
// der Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH' ? (responseSuccess ? 'LOW' : 'HIGH') : undefined,
resourceType: mapping.type,
resourceId,
resourceLabel,
endpoint: req.path,
httpMethod: req.method,
ipAddress: getClientIp(req),
userAgent: req.headers['user-agent'],
changesBefore: auditContext?.before,
changesAfter: auditContext?.after,
dataSubjectId,
success: responseSuccess,
errorMessage: !responseSuccess && responseBody && typeof responseBody === 'object' && 'error' in responseBody
? (responseBody as { error: string }).error
: undefined,
durationMs,
});
} catch (error) {
// Audit-Logging darf niemals die Anwendung beeinträchtigen
console.error('[AuditMiddleware] Fehler beim Logging:', error);
}
});
});
next();
}