aa0900410b
31.1 Stored XSS in Vertragsfeldern: providerName, tariffName, priceFirst12Months, priceFrom13Months, priceAfter24Months nahmen rohe HTML-/Script-Payloads (<script>, <svg/onload>, <img onerror>, javascript:, HTML-Entities) an und lieferten sie 1:1 an Portal-User zurueck. Fix: rekursiver sanitizeContractBody()-Walker im contract.controller, strippt String-Werte ueber das bestehende stripHtml() (Tag-Strip + URI-Schema-Block + Entity-Decode). Verträge enthalten keine legitimen HTML-Felder, deshalb safe. Audit-Vergleich nutzt jetzt die sanitisierte Variante, sonst Audit ↔ DB-Drift. 31.2 IDOR auf GET /api/customers/:id/stressfrei-emails (+5 weitere): requireCustomerAccess short-circuitete auf customers:read. Portal- User haben aber genau diese Perm im JWT (für eigene Daten) – damit kam Portal-Kunde 1 an Adressen/Bank-Cards/Documents/Meters/ Stressfrei-Emails von Kunde 3. Fix im Middleware: erst isCustomerPortal-Check (eigene + vertretene IDs), DANN erst Perm-Check für Mitarbeiter. Mit einem Patch alle sechs requireCustomerAccess-Routes dicht. Defense-in-Depth: zusätzlicher canAccessCustomer-Call in stressfreiEmail.getEmailsByCustomer analog zum POST-Handler. Live-verifiziert auf dev: - Portal-User 1 → Customer 3: alle 6 Routes 403 - XSS-Payloads in 5 Contract-Feldern → DB enthält bereinigte Werte Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
6.8 KiB
TypeScript
202 lines
6.8 KiB
TypeScript
import { Response, NextFunction } from 'express';
|
||
import jwt from 'jsonwebtoken';
|
||
import prisma from '../lib/prisma.js';
|
||
import { AuthRequest, JwtPayload } from '../types/index.js';
|
||
import { emit as emitSecurityEvent } from '../services/securityMonitor.service.js';
|
||
|
||
export async function authenticate(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
next: NextFunction
|
||
): Promise<void> {
|
||
const authHeader = req.headers.authorization;
|
||
|
||
// Token aus Header oder Query-Parameter (für Downloads)
|
||
let token: string | null = null;
|
||
let tokenSource: 'header' | 'query' | null = null;
|
||
|
||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||
token = authHeader.split(' ')[1];
|
||
tokenSource = 'header';
|
||
} else if (req.query.token && typeof req.query.token === 'string') {
|
||
// Fallback für Downloads: Token als Query-Parameter
|
||
token = req.query.token;
|
||
tokenSource = 'query';
|
||
}
|
||
|
||
if (!token) {
|
||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// JWT_SECRET wird beim Server-Start geprüft (Fail-Fast in index.ts)
|
||
// Algorithmus explizit auf HS256 festlegen (Defense-in-Depth gegen alg-confusion).
|
||
const decoded = jwt.verify(token, process.env.JWT_SECRET as string, {
|
||
algorithms: ['HS256'],
|
||
}) as JwtPayload & { type?: string };
|
||
|
||
// Defense-in-Depth: Refresh-Tokens haben `type: 'refresh'` und dürfen
|
||
// NICHT für normale API-Calls verwendet werden – nur am /api/auth/refresh-
|
||
// Endpoint. Legacy-Tokens (vor der Refresh-Token-Einführung) haben kein
|
||
// `type` und werden als Access akzeptiert, damit bestehende Sessions nicht
|
||
// zwangsabgemeldet werden.
|
||
if (decoded.type === 'refresh') {
|
||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
||
return;
|
||
}
|
||
// Download-Tokens sind kurzlebig (60s) und dürfen NUR per `?token=`
|
||
// genutzt werden, NIE als Bearer-Header. Damit kann ein in einer URL
|
||
// geleakter Download-Token nicht für reguläre API-Aufrufe missbraucht
|
||
// werden (Pentest Runde 7 – NIEDRIG, Token-in-URL-Defense).
|
||
if (decoded.type === 'download' && tokenSource !== 'query') {
|
||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
||
return;
|
||
}
|
||
if (decoded.type && decoded.type !== 'access' && decoded.type !== 'download') {
|
||
res.status(401).json({ success: false, error: 'Falscher Token-Typ' });
|
||
return;
|
||
}
|
||
|
||
// 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 },
|
||
});
|
||
|
||
if (!user || !user.isActive) {
|
||
res.status(401).json({ success: false, error: 'Benutzer nicht mehr aktiv' });
|
||
return;
|
||
}
|
||
|
||
if (user.tokenInvalidatedAt) {
|
||
const tokenIssuedAt = decoded.iat * 1000;
|
||
if (tokenIssuedAt < user.tokenInvalidatedAt.getTime()) {
|
||
res.status(401).json({
|
||
success: false,
|
||
error: 'Ihre Berechtigungen wurden geändert. Bitte melden Sie sich erneut an.',
|
||
});
|
||
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;
|
||
next();
|
||
} catch (err) {
|
||
// JWT-Failures sind interessant: alg=none, manipulierte Signature,
|
||
// expired Token. Emit SecurityEvent (asynchron, blockt nicht).
|
||
emitSecurityEvent({
|
||
type: 'TOKEN_REJECTED',
|
||
severity: err instanceof jwt.TokenExpiredError ? 'LOW' : 'HIGH',
|
||
message: err instanceof Error ? `JWT abgelehnt: ${err.message}` : 'JWT abgelehnt',
|
||
ipAddress: req.ip || (req.socket as any)?.remoteAddress || 'unknown',
|
||
endpoint: `${req.method} ${req.path}`,
|
||
});
|
||
res.status(401).json({ success: false, error: 'Ungültiger Token' });
|
||
}
|
||
}
|
||
|
||
export function requirePermission(...requiredPermissions: string[]) {
|
||
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||
if (!req.user) {
|
||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
|
||
return;
|
||
}
|
||
|
||
const userPermissions = req.user.permissions || [];
|
||
|
||
// Check if user has any of the required permissions
|
||
const hasPermission = requiredPermissions.some((perm) =>
|
||
userPermissions.includes(perm)
|
||
);
|
||
|
||
if (!hasPermission) {
|
||
res.status(403).json({
|
||
success: false,
|
||
error: 'Keine Berechtigung für diese Aktion',
|
||
});
|
||
return;
|
||
}
|
||
|
||
next();
|
||
};
|
||
}
|
||
|
||
// Middleware to check if user can access specific customer data
|
||
export function requireCustomerAccess(
|
||
req: AuthRequest,
|
||
res: Response,
|
||
next: NextFunction
|
||
): void {
|
||
if (!req.user) {
|
||
res.status(401).json({ success: false, error: 'Nicht authentifiziert' });
|
||
return;
|
||
}
|
||
|
||
// WICHTIG: erst die isCustomerPortal-Prüfung, DANN erst die Perm-Prüfung.
|
||
// Portal-User bekommen `customers:read` im JWT (für eigene Daten); ohne
|
||
// den Portal-Check vorne weg short-circuited die alte Logik auf der
|
||
// Perm und ließ Portal-User auf fremde customerId zugreifen.
|
||
// Pentest 2026-05-24 (MEDIUM 31.2 IDOR auf /api/customers/:id/
|
||
// stressfrei-emails). Auch andere Routes mit dem gleichen Middleware-
|
||
// Pattern wären betroffen gewesen.
|
||
const userPermissions = req.user.permissions || [];
|
||
const isPortal = !!(req.user as any).isCustomerPortal;
|
||
const customerId = parseInt(req.params.customerId || req.params.id);
|
||
|
||
if (isPortal) {
|
||
const allowedIds = [
|
||
req.user.customerId,
|
||
...((req.user as any).representedCustomerIds || []),
|
||
].filter(Boolean);
|
||
if (allowedIds.includes(customerId)) {
|
||
next();
|
||
return;
|
||
}
|
||
res.status(403).json({
|
||
success: false,
|
||
error: 'Kein Zugriff auf diese Kundendaten',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Mitarbeiter/Admin: customers:read oder customers:update reicht
|
||
if (
|
||
userPermissions.includes('customers:read') ||
|
||
userPermissions.includes('customers:update')
|
||
) {
|
||
next();
|
||
return;
|
||
}
|
||
|
||
res.status(403).json({
|
||
success: false,
|
||
error: 'Kein Zugriff auf diese Kundendaten',
|
||
});
|
||
}
|