Security-Hardening: IDOR-Fixes, XSS-Sanitizer, CORS+Helmet, Data-Exposure
Umfassender Security-Review vor öffentlichem Deployment. Detaillierter Report in docs/SECURITY-REVIEW.md. 🔴 KRITISCHE FIXES: 1. CORS offen → jetzt nur explizite Origins (via CORS_ORIGINS env), in Production per default komplett aus (gleiche Origin erzwingt Browser). 2. Keine Security-Headers → helmet-Middleware hinzugefügt. X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy, CORP. 3. JWT-Fallback-Secret entfernt. Beim Server-Start wird jetzt geprüft ob JWT_SECRET (min 32 Zeichen) und ENCRYPTION_KEY (exakt 64 Hex) gesetzt sind, sonst Fail-Fast mit klarer Fehlermeldung. 4. IDOR bei 7 Contract-Endpoints. Portal-Kunden mit 'contracts:read' konnten über geratene IDs fremde Daten abrufen (Passwort, SIM-PIN/PUK, Internet-Zugangsdaten, SIP-Credentials, Vertragsdokumente, Rechnungen). Neuer Helper canAccessContract() in utils/accessControl.ts in allen betroffenen Endpoints eingebaut. Prüft Vertrag-Besitzer + Vollmachten. 5. XSS via Email-Body. email.htmlBody wurde ungefiltert via dangerouslySetInnerHTML gerendert. Angreifer konnte Mail mit <script> schicken → Token-Diebstahl aus localStorage. Jetzt mit DOMPurify sanitized: verbietet script/iframe/form/inline-handler, erlaubt normale Formatierung + Bilder. 6. Customer-API leakte sensible Felder: - portalPasswordHash (bcrypt-Hash) - portalPasswordEncrypted (symmetrisch, mit ENCRYPTION_KEY entschlüsselbar) - portalPasswordResetToken (gültig 2h) Neuer Sanitizer in utils/sanitize.ts, angewendet in getCustomer/getCustomers. Admin mit customers:update darf portalPasswordEncrypted sehen (für UI-Anzeige), alle anderen Rollen nicht. 🟡 WICHTIGE FIXES: 7. Portal-JWT-Invalidation nach Passwort-Reset. Neues Feld Customer.portalTokenInvalidatedAt, wird beim Reset auf now() gesetzt. Auth-Middleware prüft Portal-Sessions dagegen. Alte Sessions werden dadurch invalidiert. 8. express.json() mit 5 MB Size-Limit (statt Default 100 KB unklar). Neue Files: - backend/src/utils/accessControl.ts - IDOR-Schutz - backend/src/utils/sanitize.ts - Response-Sanitizer - docs/SECURITY-REVIEW.md - vollständiger Report + Deployment-Checkliste Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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?
|
||||
|
||||
@@ -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<void> {
|
||||
try {
|
||||
@@ -254,9 +255,12 @@ export async function createFollowUp(req: AuthRequest, res: Response): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContractPassword(req: Request, res: Response): Promise<void> {
|
||||
export async function getContractPassword(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
export async function getSimCardCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
export async function getInternetCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
export async function getSipCredentials(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
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' },
|
||||
|
||||
@@ -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<void> {
|
||||
export async function getCustomers(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomer(req: Request, res: Response): Promise<void> {
|
||||
export async function getCustomer(req: AuthRequest, res: Response): Promise<void> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<void>
|
||||
|
||||
// ==================== CONTRACT-BASIERTE RECHNUNGEN (für alle Vertragstypen) ====================
|
||||
|
||||
export async function getInvoicesByContract(req: Request, res: Response): Promise<void> {
|
||||
export async function getInvoicesByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
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<void> {
|
||||
export async function addInvoiceByContract(req: AuthRequest, res: Response): Promise<void> {
|
||||
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),
|
||||
|
||||
+41
-3
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<boolean> {
|
||||
// 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<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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<T extends Record<string, unknown>>(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<T extends Record<string, unknown>>(customer: T | null): T | null {
|
||||
if (!customer) return customer;
|
||||
const copy = sanitizeCustomer(customer) as Record<string, unknown> | null;
|
||||
if (!copy) return null;
|
||||
delete copy.portalPasswordEncrypted;
|
||||
return copy as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize-Liste von Customers.
|
||||
*/
|
||||
export function sanitizeCustomers<T extends Record<string, unknown>>(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<T extends Record<string, unknown>>(user: T | null): T | null {
|
||||
if (!user) return user;
|
||||
const copy = { ...user };
|
||||
for (const field of SENSITIVE_USER_FIELDS) {
|
||||
delete copy[field];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user