Files
opencrm/backend/src/middleware/auth.ts
T
duffyduck aa0900410b Pentest 2026-05-24 Pen-31-Befunde (2x MEDIUM)
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>
2026-05-24 15:38:16 +02:00

202 lines
6.8 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 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',
});
}