import { Request, Response } from 'express'; import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as providerService from '../services/provider.service.js'; import { logChange } from '../services/audit.service.js'; import { ApiResponse, AuthRequest } from '../types/index.js'; // Pentest 47.1 (MEDIUM, 2026-06-01): Open Redirect / Phishing via // provider.portalUrl. Bei kompromittiertem Admin-Account konnte ein // Angreifer einen Phishing-Link auf einem real existierenden Provider // hinterlegen – jeder Portal-User mit dem Provider sah ihn dauerhaft. // Re-Auth-Pattern analog 47.3 (Staff-Password): bei Änderung der // portalUrl-Domain muss der Admin sein eigenes Passwort mitsenden. async function requireCallerPasswordReAuth(req: AuthRequest, providedPassword: unknown): Promise<{ ok: true } | { ok: false; status: number; error: string }> { const callerId = req.user?.userId; if (!callerId) return { ok: false, status: 401, error: 'Nicht authentifiziert' }; if (typeof providedPassword !== 'string' || providedPassword.length === 0) { return { ok: false, status: 400, error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.' }; } const caller = await prisma.user.findUnique({ where: { id: callerId }, select: { password: true } }); // Timing-Schutz: immer einen bcrypt.compare laufen lassen const callerHash = caller?.password ?? '$2a$10$0000000000000000000000000000000000000000000000000000'; const ok = await bcrypt.compare(providedPassword, callerHash); if (!caller || !ok) return { ok: false, status: 403, error: 'Eigenes Passwort ist falsch.' }; return { ok: true }; } // Pentest 49.1 (LOW, 2026-06-01): nur Host-Vergleich ließ Pfad-Änderungen // am gleichen Host (z.B. `https://1und1.de/neue/pfad`) ohne Re-Auth // durchgehen – ein gestohlener JWT konnte Phishing-Pfade auf trusted // Domains plazieren. Jetzt vergleichen wir die komplette normalisierte // URL (Trailing-Slash, Whitespace). function normalizeUrlForCompare(url: string | null | undefined): string { return (url ?? '').trim().replace(/\/+$/, '').toLowerCase(); } export async function getProviders(req: Request, res: Response): Promise { try { const includeInactive = req.query.includeInactive === 'true'; const providers = await providerService.getAllProviders(includeInactive); res.json({ success: true, data: providers } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden der Anbieter', } as ApiResponse); } } export async function getProvider(req: Request, res: Response): Promise { try { // `req.params.id` ist Pfad-Segment – bei /api/providers/email landet // hier der String "email", den parseInt zu NaN macht. Ohne Validierung // fuhr Prisma dann gegen `WHERE id = NaN` und warf 500. // Pentest 2026-05-20, 29.5: explizit 404 statt 500. Andere Sub-Routes // wie /api/providers//tariffs greifen weiter wie gehabt. const id = parseInt(req.params.id, 10); if (!Number.isFinite(id) || id < 1) { res.status(404).json({ success: false, error: 'Anbieter nicht gefunden', } as ApiResponse); return; } const provider = await providerService.getProviderById(id); if (!provider) { res.status(404).json({ success: false, error: 'Anbieter nicht gefunden', } as ApiResponse); return; } res.json({ success: true, data: provider } as ApiResponse); } catch (error) { res.status(500).json({ success: false, error: 'Fehler beim Laden des Anbieters', } as ApiResponse); } } export async function createProvider(req: Request, res: Response): Promise { try { const { currentPassword, ...providerData } = req.body || {}; // 47.1: Beim Create mit portalUrl ist Re-Auth Pflicht. Ohne portalUrl // (rein interner Provider-Stammdatensatz) kein Zwang. if (providerData.portalUrl) { const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword); if (!reauth.ok) { res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse); return; } } const provider = await providerService.createProvider(providerData); await logChange({ req, action: 'CREATE', resourceType: 'Provider', resourceId: provider.id.toString(), label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`, }); res.status(201).json({ success: true, data: provider } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Erstellen des Anbieters', } as ApiResponse); } } export async function updateProvider(req: Request, res: Response): Promise { try { const providerId = parseInt(req.params.id); const { currentPassword, ...providerData } = req.body || {}; // 47.1 + 49.1: jede portalUrl-Änderung braucht Re-Auth – inkl. reiner // Pfad-Änderungen am gleichen Host (Phishing-Pfade auf trusted Domain). // Reine Namens-/Tarif-Edits bleiben friction-frei. if (providerData.portalUrl !== undefined) { const before = await providerService.getProviderById(providerId); const isUrlChange = normalizeUrlForCompare(before?.portalUrl) !== normalizeUrlForCompare(providerData.portalUrl); if (isUrlChange) { const reauth = await requireCallerPasswordReAuth(req as AuthRequest, currentPassword); if (!reauth.ok) { res.status(reauth.status).json({ success: false, error: reauth.error } as ApiResponse); return; } } } const provider = await providerService.updateProvider(providerId, providerData); await logChange({ req, action: 'UPDATE', resourceType: 'Provider', resourceId: provider.id.toString(), label: providerData.portalUrl !== undefined ? `Anbieter ${provider.name} aktualisiert (Portal-URL: ${provider.portalUrl ?? 'entfernt'})` : `Anbieter ${provider.name} aktualisiert`, }); res.json({ success: true, data: provider } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Aktualisieren des Anbieters', } as ApiResponse); } } export async function deleteProvider(req: Request, res: Response): Promise { try { const providerId = parseInt(req.params.id); const provider = await providerService.getProviderById(providerId); await providerService.deleteProvider(providerId); await logChange({ req, action: 'DELETE', resourceType: 'Provider', resourceId: providerId.toString(), label: `Anbieter ${provider?.name || providerId} gelöscht`, }); res.json({ success: true, message: 'Anbieter gelöscht' } as ApiResponse); } catch (error) { res.status(400).json({ success: false, error: error instanceof Error ? error.message : 'Fehler beim Löschen des Anbieters', } as ApiResponse); } }