Files
opencrm/backend/src/controllers/provider.controller.ts
T
duffyduck 57eb29c2a6 Pentest 49.1 LOW: Re-Auth jetzt auf JEDE portalUrl-Änderung
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>
2026-06-01 13:46:56 +02:00

164 lines
7.0 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 { 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);
}
}