diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 250938cf..543b5634 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -170,6 +170,8 @@ model Customer { // Portal Passwort-Reset portalPasswordResetToken String? @unique portalPasswordResetExpiresAt DateTime? + // Portal Session-Invalidation (nach Passwort-Reset / Rechte-Änderung) + portalTokenInvalidatedAt DateTime? // Geburtstagsmodal: Jahr in dem dem Kunden der Geburtstagsgruß gezeigt wurde (vermeidet mehrfaches Anzeigen) lastBirthdayGreetingYear Int? diff --git a/backend/src/controllers/contract.controller.ts b/backend/src/controllers/contract.controller.ts index ca0cb298..64e205cc 100644 --- a/backend/src/controllers/contract.controller.ts +++ b/backend/src/controllers/contract.controller.ts @@ -6,6 +6,7 @@ import * as contractHistoryService from '../services/contractHistory.service.js' import * as authorizationService from '../services/authorization.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; import { logChange } from '../services/audit.service.js'; +import { canAccessContract } from '../utils/accessControl.js'; export async function getContracts(req: AuthRequest, res: Response): Promise { try { @@ -254,9 +255,12 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise { +export async function getContractPassword(req: AuthRequest, res: Response): Promise { try { - const password = await contractService.getContractPassword(parseInt(req.params.id)); + const contractId = parseInt(req.params.id); + if (!(await canAccessContract(req, res, contractId))) return; + + const password = await contractService.getContractPassword(contractId); if (password === null) { res.status(404).json({ success: false, @@ -273,9 +277,21 @@ export async function getContractPassword(req: Request, res: Response): Promise< } } -export async function getSimCardCredentials(req: Request, res: Response): Promise { +export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise { try { - const credentials = await contractService.getSimCardCredentials(parseInt(req.params.simCardId)); + const simCardId = parseInt(req.params.simCardId); + // SimCard → MobileDetails → Contract + const sim = await prisma.simCard.findUnique({ + where: { id: simCardId }, + select: { mobileDetails: { select: { contractId: true } } }, + }); + if (!sim?.mobileDetails) { + res.status(404).json({ success: false, error: 'SIM-Karte nicht gefunden' } as ApiResponse); + return; + } + if (!(await canAccessContract(req, res, sim.mobileDetails.contractId))) return; + + const credentials = await contractService.getSimCardCredentials(simCardId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ @@ -285,9 +301,12 @@ export async function getSimCardCredentials(req: Request, res: Response): Promis } } -export async function getInternetCredentials(req: Request, res: Response): Promise { +export async function getInternetCredentials(req: AuthRequest, res: Response): Promise { try { - const credentials = await contractService.getInternetCredentials(parseInt(req.params.id)); + const contractId = parseInt(req.params.id); + if (!(await canAccessContract(req, res, contractId))) return; + + const credentials = await contractService.getInternetCredentials(contractId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ @@ -297,9 +316,21 @@ export async function getInternetCredentials(req: Request, res: Response): Promi } } -export async function getSipCredentials(req: Request, res: Response): Promise { +export async function getSipCredentials(req: AuthRequest, res: Response): Promise { try { - const credentials = await contractService.getSipCredentials(parseInt(req.params.phoneNumberId)); + const phoneNumberId = parseInt(req.params.phoneNumberId); + // PhoneNumber → InternetDetails → Contract + const phone = await prisma.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + select: { internetDetails: { select: { contractId: true } } }, + }); + if (!phone?.internetDetails) { + res.status(404).json({ success: false, error: 'Rufnummer nicht gefunden' } as ApiResponse); + return; + } + if (!(await canAccessContract(req, res, phone.internetDetails.contractId))) return; + + const credentials = await contractService.getSipCredentials(phoneNumberId); res.json({ success: true, data: credentials } as ApiResponse); } catch (error) { res.status(500).json({ @@ -415,6 +446,8 @@ export async function removeContractMeter(req: AuthRequest, res: Response): Prom export async function getContractDocuments(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); + if (!(await canAccessContract(req, res, contractId))) return; + const documents = await prisma.contractDocument.findMany({ where: { contractId }, orderBy: { createdAt: 'desc' }, diff --git a/backend/src/controllers/customer.controller.ts b/backend/src/controllers/customer.controller.ts index 4b435c6b..acffd037 100644 --- a/backend/src/controllers/customer.controller.ts +++ b/backend/src/controllers/customer.controller.ts @@ -4,9 +4,10 @@ import * as customerService from '../services/customer.service.js'; import * as authService from '../services/auth.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; +import { sanitizeCustomer, sanitizeCustomers, sanitizeCustomerStrict } from '../utils/sanitize.js'; // Customer CRUD -export async function getCustomers(req: Request, res: Response): Promise { +export async function getCustomers(req: AuthRequest, res: Response): Promise { try { const { search, type, page, limit } = req.query; const result = await customerService.getAllCustomers({ @@ -15,7 +16,12 @@ export async function getCustomers(req: Request, res: Response): Promise { page: page ? parseInt(page as string) : undefined, limit: limit ? parseInt(limit as string) : undefined, }); - res.json({ success: true, data: result.customers, pagination: result.pagination } as ApiResponse); + // Portal-Kunden oder andere Rollen sehen kein portalPasswordEncrypted + const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; + const sanitized = canSeePasswords + ? sanitizeCustomers(result.customers as any) + : (result.customers as any[]).map((c) => sanitizeCustomerStrict(c)).filter(Boolean); + res.json({ success: true, data: sanitized, pagination: result.pagination } as ApiResponse); } catch (error) { res.status(500).json({ success: false, @@ -24,14 +30,19 @@ export async function getCustomers(req: Request, res: Response): Promise { } } -export async function getCustomer(req: Request, res: Response): Promise { +export async function getCustomer(req: AuthRequest, res: Response): Promise { try { const customer = await customerService.getCustomerById(parseInt(req.params.id)); if (!customer) { res.status(404).json({ success: false, error: 'Kunde nicht gefunden' } as ApiResponse); return; } - res.json({ success: true, data: customer } as ApiResponse); + // Portal-Kunden/Read-only sehen kein portalPasswordEncrypted + const canSeePasswords = req.user?.permissions?.includes('customers:update') ?? false; + const sanitized = canSeePasswords + ? sanitizeCustomer(customer as any) + : sanitizeCustomerStrict(customer as any); + res.json({ success: true, data: sanitized } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden des Kunden' } as ApiResponse); } diff --git a/backend/src/controllers/invoice.controller.ts b/backend/src/controllers/invoice.controller.ts index 919e7d2e..f580929c 100644 --- a/backend/src/controllers/invoice.controller.ts +++ b/backend/src/controllers/invoice.controller.ts @@ -1,7 +1,8 @@ import { Request, Response } from 'express'; import * as invoiceService from '../services/invoice.service.js'; import { logChange } from '../services/audit.service.js'; -import { ApiResponse } from '../types/index.js'; +import { ApiResponse, AuthRequest } from '../types/index.js'; +import { canAccessContract } from '../utils/accessControl.js'; /** * Alle Rechnungen für ein EnergyContractDetails abrufen @@ -146,9 +147,10 @@ export async function deleteInvoice(req: Request, res: Response): Promise // ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ==================== -export async function getInvoicesByContract(req: Request, res: Response): Promise { +export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); + if (!(await canAccessContract(req, res, contractId))) return; const invoices = await invoiceService.getInvoicesByContract(contractId); res.json({ success: true, data: invoices } as ApiResponse); } catch (error) { @@ -156,9 +158,10 @@ export async function getInvoicesByContract(req: Request, res: Response): Promis } } -export async function addInvoiceByContract(req: Request, res: Response): Promise { +export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise { try { const contractId = parseInt(req.params.id); + if (!(await canAccessContract(req, res, contractId))) return; const { invoiceDate, invoiceType, notes } = req.body; const invoice = await invoiceService.addInvoiceByContract(contractId, { invoiceDate: new Date(invoiceDate), diff --git a/backend/src/index.ts b/backend/src/index.ts index d1a01afe..589f330d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import helmet from 'helmet'; import path from 'path'; import dotenv from 'dotenv'; @@ -39,12 +40,49 @@ import { auditMiddleware } from './middleware/audit.js'; dotenv.config(); +// ==================== SECURITY: Pflicht-Umgebungsvariablen prüfen ==================== +if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) { + console.error('❌ JWT_SECRET ist nicht gesetzt oder zu kurz (min. 32 Zeichen)'); + console.error(' Generiere mit: openssl rand -hex 64'); + process.exit(1); +} +if (!process.env.ENCRYPTION_KEY || process.env.ENCRYPTION_KEY.length !== 64) { + console.error('❌ ENCRYPTION_KEY ist nicht gesetzt oder hat nicht exakt 64 Hex-Zeichen (32 Byte)'); + console.error(' Generiere mit: openssl rand -hex 32'); + process.exit(1); +} + const app = express(); const PORT = process.env.PORT || 3001; -// Middleware -app.use(cors()); -app.use(express.json()); +// ==================== SECURITY MIDDLEWARE ==================== + +// HTTP Security Headers (X-Frame-Options, X-Content-Type-Options, HSTS, etc.) +app.use( + helmet({ + // CSP ausschalten – wird bei SPA schwierig, frontend setzt eigene CSP via meta + contentSecurityPolicy: false, + // Cross-Origin-Resource-Policy: "same-site" für SPA mit gleicher Origin + crossOriginResourcePolicy: { policy: 'same-site' }, + }), +); + +// CORS: in Production nur explizit erlaubte Origins. In Dev: alles erlauben. +const corsOrigins = process.env.CORS_ORIGINS + ? process.env.CORS_ORIGINS.split(',').map((s) => s.trim()) + : process.env.NODE_ENV === 'production' + ? false // Gar kein Cross-Origin zulässig (Frontend wird unter gleicher Origin ausgeliefert) + : true; // Dev: alles erlauben + +app.use( + cors({ + origin: corsOrigins, + credentials: true, + }), +); + +// JSON-Body-Limit: 5 MB (Uploads laufen über multer, brauchen kein json()) +app.use(express.json({ limit: '5mb' })); // Audit-Logging Middleware (DSGVO-konform) app.use(auditContextMiddleware); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index a61042f4..32ee6adb 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -26,27 +26,24 @@ export async function authenticate( } try { - const decoded = jwt.verify( - token, - process.env.JWT_SECRET || 'fallback-secret' - ) as JwtPayload; + // JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts) + const decoded = jwt.verify(token, process.env.JWT_SECRET as string) as JwtPayload; - // Prüfen ob Token durch Rechteänderung invalidiert wurde (nur für Mitarbeiter) + // Prüfen ob Token durch Rechteänderung/Passwort-Reset invalidiert wurde if (decoded.userId && decoded.iat) { + // Mitarbeiter-Login const user = await prisma.user.findUnique({ where: { id: decoded.userId }, select: { tokenInvalidatedAt: true, isActive: true }, }); - // Benutzer nicht gefunden oder deaktiviert if (!user || !user.isActive) { res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' }); return; } - // Token wurde vor der Invalidierung ausgestellt if (user.tokenInvalidatedAt) { - const tokenIssuedAt = decoded.iat * 1000; // iat ist in Sekunden, Date ist in Millisekunden + const tokenIssuedAt = decoded.iat * 1000; if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) { res.status(401).json({ success: false, @@ -55,6 +52,28 @@ export async function authenticate( return; } } + } else if (decoded.isCustomerPortal && decoded.customerId && decoded.iat) { + // Portal-Kunden-Login: gleiche Prüfung + const customer = await prisma.customer.findUnique({ + where: { id: decoded.customerId }, + select: { portalTokenInvalidatedAt: true, portalEnabled: true }, + }); + + if (!customer || !customer.portalEnabled) { + res.status(401).json({ success: false, error: 'Portal-Zugang nicht mehr aktiv' }); + return; + } + + if (customer.portalTokenInvalidatedAt) { + const tokenIssuedAt = decoded.iat * 1000; + if (tokenIssuedAt < customer.portalTokenInvalidatedAt.getTime()) { + res.status(401).json({ + success: false, + error: 'Ihre Sitzung ist ungültig. Bitte melden Sie sich erneut an.', + }); + return; + } + } } req.user = decoded; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index 58de5cc5..74855497 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -55,7 +55,7 @@ export async function login(email: string, password: string) { isCustomerPortal: false, }; - const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', { + const token = jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], }); @@ -138,7 +138,7 @@ export async function customerLogin(email: string, password: string) { representedCustomerIds, }; - const token = jwt.sign(payload, process.env.JWT_SECRET || 'fallback-secret', { + const token = jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: (process.env.JWT_EXPIRES_IN || '7d') as jwt.SignOptions['expiresIn'], }); @@ -501,6 +501,8 @@ export async function confirmPasswordReset(token: string, newPassword: string): portalPasswordEncrypted: encrypt(newPassword), portalPasswordResetToken: null, portalPasswordResetExpiresAt: null, + // Alle bestehenden Portal-Sessions kicken + portalTokenInvalidatedAt: new Date(), }, }); return; diff --git a/backend/src/utils/accessControl.ts b/backend/src/utils/accessControl.ts new file mode 100644 index 00000000..69c45dd5 --- /dev/null +++ b/backend/src/utils/accessControl.ts @@ -0,0 +1,107 @@ +/** + * Access-Control-Helper für Portal-Kunden-Isolation. + * + * Portal-Kunden haben die Permission `contracts:read` / `customers:read`, damit + * sie ihre eigenen Daten sehen können. Damit sie aber NICHT fremde Daten über + * geratene IDs abrufen (IDOR), muss bei jedem Endpoint der eine sensible + * Ressource (Vertrag, Rechnung, Passwort, ...) zurückliefert, der Kunde auf + * Besitz/Vollmacht geprüft werden. + */ +import { Response } from 'express'; +import prisma from '../lib/prisma.js'; +import * as authorizationService from '../services/authorization.service.js'; +import { AuthRequest } from '../types/index.js'; + +/** + * Prüft ob der authentifizierte User auf einen bestimmten Vertrag zugreifen darf. + * - Mitarbeiter/Admin mit customers:read / contracts:read: ja, immer + * - Portal-Kunde: nur wenn contract.customerId = eigener customerId ODER + * wenn er einen Vertreter für diesen Kunden ist MIT gültiger Vollmacht + * + * @returns true = erlaubt, false = Zugriff verweigert (Response wurde bereits gesendet) + */ +export async function canAccessContract( + req: AuthRequest, + res: Response, + contractId: number, +): Promise { + // Nicht-Portal-User (Mitarbeiter/Admin) kommen hier immer durch, wenn sie die Permission haben + if (!req.user?.isCustomerPortal) { + return true; + } + + if (!req.user.customerId) { + res.status(403).json({ success: false, error: 'Kein Zugriff' }); + return false; + } + + // Vertrag laden, Besitzer-ID prüfen + const contract = await prisma.contract.findUnique({ + where: { id: contractId }, + select: { customerId: true }, + }); + + if (!contract) { + res.status(404).json({ success: false, error: 'Vertrag nicht gefunden' }); + return false; + } + + // Eigene Verträge = immer erlaubt + if (contract.customerId === req.user.customerId) { + return true; + } + + // Fremde Verträge nur mit aktiver Vollmacht + const representedIds: number[] = (req.user as any).representedCustomerIds || []; + if (!representedIds.includes(contract.customerId)) { + res.status(403).json({ success: false, error: 'Kein Zugriff auf diesen Vertrag' }); + return false; + } + + const hasAuth = await authorizationService.hasAuthorization( + contract.customerId, + req.user.customerId, + ); + if (!hasAuth) { + res.status(403).json({ success: false, error: 'Vollmacht erforderlich' }); + return false; + } + + return true; +} + +/** + * Prüft Zugriff auf einen Kunden (analog zu canAccessContract). + */ +export async function canAccessCustomer( + req: AuthRequest, + res: Response, + customerId: number, +): Promise { + if (!req.user?.isCustomerPortal) { + return true; + } + + if (!req.user.customerId) { + res.status(403).json({ success: false, error: 'Kein Zugriff' }); + return false; + } + + if (customerId === req.user.customerId) { + return true; + } + + const representedIds: number[] = (req.user as any).representedCustomerIds || []; + if (!representedIds.includes(customerId)) { + res.status(403).json({ success: false, error: 'Kein Zugriff auf diese Kundendaten' }); + return false; + } + + const hasAuth = await authorizationService.hasAuthorization(customerId, req.user.customerId); + if (!hasAuth) { + res.status(403).json({ success: false, error: 'Vollmacht erforderlich' }); + return false; + } + + return true; +} diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts new file mode 100644 index 00000000..5ac78250 --- /dev/null +++ b/backend/src/utils/sanitize.ts @@ -0,0 +1,68 @@ +/** + * Sanitize-Helpers: entfernen sensible Felder aus DB-Ergebnissen, bevor sie + * als API-Response rausgehen. Zentrale Stelle, damit keine Passwort-Hashes, + * Verschlüsselungen oder Reset-Tokens versehentlich durch die API leaken. + */ + +// Felder die NIE in einer API-Response an den Client gehen dürfen +const SENSITIVE_CUSTOMER_FIELDS = [ + 'portalPasswordHash', + 'portalPasswordResetToken', + 'portalPasswordResetExpiresAt', +] as const; + +const SENSITIVE_USER_FIELDS = [ + 'password', + 'passwordResetToken', + 'passwordResetExpiresAt', + 'tokenInvalidatedAt', +] as const; + +/** + * Entfernt Passwort-Hash, Reset-Token etc. aus einem Customer-Objekt. + * `portalPasswordEncrypted` bleibt nur drin, wenn der Caller Admin-Rechte hat + * (wird in einem zweiten Schritt vom Controller gemacht). Dieser Helper entfernt + * es standardmäßig. + */ +export function sanitizeCustomer>(customer: T | null): T | null { + if (!customer) return customer; + const copy = { ...customer }; + for (const field of SENSITIVE_CUSTOMER_FIELDS) { + delete copy[field]; + } + // portalPasswordEncrypted bleibt hier zunächst drin, damit Mitarbeiter das + // Portal-Passwort ggf. in der UI anzeigen können. Wird per requirePermission + // auf 'customers:update' implizit gesichert. + return copy; +} + +/** + * Entfernt portalPasswordEncrypted zusätzlich zu den anderen sensiblen Feldern. + * Für Kontexte in denen der Caller KEIN Admin ist (z.B. Portal-Kunde). + */ +export function sanitizeCustomerStrict>(customer: T | null): T | null { + if (!customer) return customer; + const copy = sanitizeCustomer(customer) as Record | null; + if (!copy) return null; + delete copy.portalPasswordEncrypted; + return copy as T; +} + +/** + * Sanitize-Liste von Customers. + */ +export function sanitizeCustomers>(customers: T[]): T[] { + return customers.map((c) => sanitizeCustomer(c)).filter((c): c is T => c !== null); +} + +/** + * Sanitize User-Objekt für API-Responses. + */ +export function sanitizeUser>(user: T | null): T | null { + if (!user) return user; + const copy = { ...user }; + for (const field of SENSITIVE_USER_FIELDS) { + delete copy[field]; + } + return copy; +} diff --git a/backend/todo.md b/backend/todo.md index 95ec7d9b..c3410217 100644 --- a/backend/todo.md +++ b/backend/todo.md @@ -97,6 +97,20 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🛡️ Security-Review + Hardening vor Production-Deployment** + - Vollständiger Review aller kritischen Bereiche, dokumentiert in **[docs/SECURITY-REVIEW.md](../docs/SECURITY-REVIEW.md)** + - **6 kritische Findings gefixt:** + - CORS offen → explizit konfigurierbar über `CORS_ORIGINS` + - Helmet (Security-Headers) hinzugefügt + - JWT-Fallback-Secret entfernt, ENV-Pflicht-Check beim Start + - IDOR bei 7 sensiblen Contract-Endpoints (Portal-Kunden konnten fremde Credentials abrufen) + - XSS via Email-Body (DOMPurify als Sanitizer) + - Customer-API leakte Passwort-Hashes + Reset-Tokens + - **2 wichtige Findings gefixt:** + - Portal-JWT-Invalidation nach Passwort-Reset (`Customer.portalTokenInvalidatedAt`) + - Body-Size-Limit auf 5 MB + - Deployment-Checkliste dokumentiert (neue Secrets generieren, HSTS, DB-User-Rechte, Backup-Cron) + - [x] **🎉 Version 1.0.0 Feinschliff: Passwort-Reset + Rate-Limiting + Auto-Geburtstagsgrüße** - **Passwort vergessen-Flow** (Login → "Passwort vergessen?" Link) - Email-Reset-Token mit 2h Gültigkeit (kryptografisch sicher: 32 Byte Random) diff --git a/docs/SECURITY-REVIEW.md b/docs/SECURITY-REVIEW.md new file mode 100644 index 00000000..07808121 --- /dev/null +++ b/docs/SECURITY-REVIEW.md @@ -0,0 +1,154 @@ +# Security-Review vor 1.0.0 + +Systematischer Review des Codebase mit Fokus auf Produktions-Hardening +vor öffentlichem Deployment (hinter HTTPS-Proxy). + +## Gefundene Probleme & Fixes + +### 🔴 KRITISCH (sofort gefixt) + +#### 1. CORS komplett offen +**Vorher:** `app.use(cors())` – jede Origin darf Requests senden. +**Risiko:** Fremde Websites können bei eingeloggtem User Requests mit dessen +JWT durchführen (wenn Token in Cookies wäre – bei localStorage weniger relevant, +aber trotzdem schlechte Praxis). +**Fix:** CORS nur für explizit konfigurierte Origins (via `CORS_ORIGINS` ENV), +in Production per Default komplett aus (Frontend läuft unter gleicher Origin). + +#### 2. Keine Security-Headers (Helmet fehlt) +**Vorher:** Keine HTTP-Security-Headers gesetzt. +**Risiko:** XSS, Clickjacking, MIME-Sniffing, Missing HSTS. +**Fix:** `helmet`-Middleware aktiviert – setzt automatisch: +X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (in HTTPS), +Cross-Origin-Resource-Policy. + +#### 3. JWT-Fallback-Secret +**Vorher:** `jwt.verify(token, process.env.JWT_SECRET || 'fallback-secret')` +**Risiko:** Wenn `.env` kaputt ist oder Secret leer → bekannter String +"fallback-secret" → **Tokens können gefälscht werden!** +**Fix:** Beim Server-Start wird geprüft, dass JWT_SECRET mindestens 32 Zeichen lang +und ENCRYPTION_KEY exakt 64 Hex-Zeichen hat. Sonst Abbruch mit klarer Fehlermeldung. +Fallback wurde aus dem Code entfernt. + +#### 4. IDOR bei sensiblen Contract-Endpoints +**Vorher:** Portal-Kunden haben `contracts:read` Permission → können über +geratene IDs auf **fremde** Daten zugreifen: +- `GET /contracts/:id/password` → Passwort im Klartext +- `GET /contracts/simcard/:id/credentials` → PIN/PUK +- `GET /contracts/:id/internet-credentials` → Internet-Passwort +- `GET /contracts/phonenumber/:id/sip-credentials` → SIP-Passwort +- `GET /contracts/:id/documents` → Vertragsdokumente +- `GET /contracts/:id/invoices` → Rechnungen +- `POST /contracts/:id/invoices` → Rechnung zu fremdem Vertrag hinzufügen +**Fix:** Neuer Helper `canAccessContract()` in `backend/src/utils/accessControl.ts`. +Wird in allen sensiblen Endpoints aufgerufen und prüft: +- Mitarbeiter/Admin → OK +- Portal-Kunde + eigener Vertrag → OK +- Portal-Kunde + vertretener Kunde MIT gültiger Vollmacht → OK +- Sonst 403 Forbidden + +#### 5. XSS via Email-Body +**Vorher:** `
` +**Risiko:** Ein Angreifer sendet Mail mit `` in HTML-Body senden, + im Email-Client öffnen → kein Alert +3. **Rate-Limit-Tests:** 11x falsch einloggen → muss blocken +4. **Password-Reset-Tests:** Reset-Link 2x nutzen → zweites Mal fehlschlägt + +## Übersicht der Code-Änderungen + +| Datei | Änderung | +|---|---| +| `backend/src/index.ts` | Helmet, CORS-Config, Body-Limit, ENV-Check beim Start | +| `backend/src/middleware/auth.ts` | JWT-Fallback raus, Portal-Token-Invalidation | +| `backend/src/services/auth.service.ts` | JWT-Fallback raus, `portalTokenInvalidatedAt` setzen | +| `backend/src/utils/accessControl.ts` | **NEU** – `canAccessContract`, `canAccessCustomer` | +| `backend/src/utils/sanitize.ts` | **NEU** – Sanitizer für Customer/User | +| `backend/src/controllers/contract.controller.ts` | IDOR-Schutz in 5 Endpoints | +| `backend/src/controllers/invoice.controller.ts` | IDOR-Schutz in 2 Endpoints | +| `backend/src/controllers/customer.controller.ts` | Sanitizer in getCustomer/getCustomers | +| `backend/prisma/schema.prisma` | `Customer.portalTokenInvalidatedAt` | +| `frontend/src/components/email/EmailDetail.tsx` | DOMPurify für htmlBody | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f5c37407..7d1a2a41 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", "axios": "^1.7.7", + "dompurify": "^3.4.1", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -22,6 +23,7 @@ "react-router-dom": "^6.28.0" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", @@ -1595,6 +1597,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1642,6 +1654,13 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -1976,6 +1995,15 @@ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", "dev": true }, + "node_modules/dompurify": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c848d9e8..d9a229c4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tiptap/react": "^3.19.0", "@tiptap/starter-kit": "^3.19.0", "axios": "^1.7.7", + "dompurify": "^3.4.1", "lucide-react": "^0.454.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -22,6 +23,7 @@ "react-router-dom": "^6.28.0" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", diff --git a/frontend/src/components/email/EmailDetail.tsx b/frontend/src/components/email/EmailDetail.tsx index f8788b82..999b1079 100644 --- a/frontend/src/components/email/EmailDetail.tsx +++ b/frontend/src/components/email/EmailDetail.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { Reply, Star, Paperclip, Link2, X, Download, ExternalLink, Trash2, Undo2, Save, FileDown } from 'lucide-react'; +import DOMPurify from 'dompurify'; import { CachedEmail, cachedEmailApi } from '../../services/api'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import Button from '../ui/Button'; @@ -384,7 +385,16 @@ export default function EmailDetail({ {showHtml && email.htmlBody ? (
) : (