57eb29c2a6
Bisher prüfte der Re-Auth-Trigger nur den Host – `https://1und1.de/foo` → `https://1und1.de/phishing/path` ging ohne currentPassword durch. Damit konnte ein gestohlener JWT Phishing-Pfade auf trusted Domains plazieren. Backend (provider.controller): normalizeUrlForCompare vergleicht jetzt die komplette URL (Trailing-Slash, Whitespace, Case), nicht nur den Host. hostOf-Helper entfernt. Frontend (ProviderModal): gleiche Normalisierung im UI, damit der Bestätigungs-Banner mit der Backend-Prüfung synchron läuft. Banner-Text leicht angepasst (nicht mehr "Domain wurde geändert" sondern generisch "Portal-URL wurde geändert"). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
7.0 KiB
TypeScript
164 lines
7.0 KiB
TypeScript
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<void> {
|
||
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<void> {
|
||
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/<id>/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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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);
|
||
}
|
||
}
|