diff --git a/backend/src/controllers/provider.controller.ts b/backend/src/controllers/provider.controller.ts index 9f9da24d..ffbeceb1 100644 --- a/backend/src/controllers/provider.controller.ts +++ b/backend/src/controllers/provider.controller.ts @@ -1,7 +1,34 @@ 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 } from '../types/index.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 }; +} + +function hostOf(url: string | null | undefined): string | null { + if (!url) return null; + try { return new URL(url).host.toLowerCase(); } catch { return null; } +} export async function getProviders(req: Request, res: Response): Promise { try { @@ -50,11 +77,21 @@ export async function getProvider(req: Request, res: Response): Promise { export async function createProvider(req: Request, res: Response): Promise { try { - const provider = await providerService.createProvider(req.body); + 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`, + label: `Anbieter ${provider.name} angelegt${provider.portalUrl ? ` mit Portal-URL ${provider.portalUrl}` : ''}`, }); res.status(201).json({ success: true, data: provider } as ApiResponse); } catch (error) { @@ -67,11 +104,33 @@ export async function createProvider(req: Request, res: Response): Promise export async function updateProvider(req: Request, res: Response): Promise { try { - const provider = await providerService.updateProvider(parseInt(req.params.id), req.body); + const providerId = parseInt(req.params.id); + const { currentPassword, ...providerData } = req.body || {}; + + // 47.1: portalUrl-Domain-Wechsel braucht Re-Auth. Wir laden den + // bisherigen Wert und vergleichen den Host – nur dann ist es ein + // sensible Operation. Reine Namens-/Tarif-Edits bleiben friction-frei. + if (providerData.portalUrl !== undefined) { + const before = await providerService.getProviderById(providerId); + const oldHost = hostOf(before?.portalUrl); + const newHost = hostOf(providerData.portalUrl); + const isDomainChange = (newHost && newHost !== oldHost) || (oldHost && !newHost); + if (isDomainChange) { + 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: `Anbieter ${provider.name} aktualisiert`, + 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) { diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index a6126df1..ce816349 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -1,8 +1,9 @@ import { Request, Response } from 'express'; +import bcrypt from 'bcryptjs'; import prisma from '../lib/prisma.js'; import * as userService from '../services/user.service.js'; import { logChange } from '../services/audit.service.js'; -import { ApiResponse } from '../types/index.js'; +import { ApiResponse, AuthRequest } from '../types/index.js'; import { pickUserCreate, pickUserUpdate, isValidEmail } from '../utils/sanitize.js'; import { validatePasswordComplexity, STAFF_MIN_PASSWORD_LENGTH } from '../utils/passwordGenerator.js'; @@ -189,11 +190,44 @@ export async function updateUser(req: Request, res: Response): Promise { export async function setUserPassword(req: Request, res: Response): Promise { try { const userId = parseInt(req.params.id); - const { password } = req.body || {}; + const { password, currentPassword } = req.body || {}; if (!password || typeof password !== 'string') { res.status(400).json({ success: false, error: 'Passwort erforderlich' } as ApiResponse); return; } + + // Pentest 47.3 (MEDIUM, 2026-06-01): Re-Auth verpflichtend. + // Ein gestohlener Admin-JWT reichte bisher, um Staff-Passwörter + // umzuschreiben. Jetzt muss der aufrufende Admin sein eigenes + // Passwort mitsenden – CSRF/Token-Klau allein reicht nicht mehr. + const authReq = req as AuthRequest; + const callerId = authReq.user?.userId; + if (!callerId) { + res.status(401).json({ success: false, error: 'Nicht authentifiziert' } as ApiResponse); + return; + } + if (!currentPassword || typeof currentPassword !== 'string') { + res.status(400).json({ + success: false, + error: 'Bitte das eigene Passwort zur Bestätigung mitsenden.', + } as ApiResponse); + return; + } + 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 reAuthOk = await bcrypt.compare(currentPassword, callerHash); + if (!caller || !reAuthOk) { + res.status(403).json({ + success: false, + error: 'Eigenes Passwort ist falsch.', + } as ApiResponse); + return; + } + const c = validatePasswordComplexity(password, { minLength: STAFF_MIN_PASSWORD_LENGTH }); if (!c.ok) { res.status(400).json({ @@ -210,7 +244,7 @@ export async function setUserPassword(req: Request, res: Response): Promise(data: T): T { + const out: any = { ...data }; + if (typeof out.name === 'string') out.name = stripHtml(out.name); + if (typeof out.usernameFieldName === 'string') out.usernameFieldName = stripHtml(out.usernameFieldName); + if (typeof out.passwordFieldName === 'string') out.passwordFieldName = stripHtml(out.passwordFieldName); + return out; +} + export async function createProvider(data: { name: string; portalUrl?: string; usernameFieldName?: string; passwordFieldName?: string; }) { - const portalUrl = assertValidPortalUrl(data.portalUrl); + const clean = stripProviderStrings(data); + const portalUrl = assertValidPortalUrl(clean.portalUrl); return prisma.provider.create({ data: { - ...data, + ...clean, portalUrl, isActive: true, }, @@ -75,7 +89,7 @@ export async function updateProvider( // portalUrl nur validieren wenn explizit mitgesendet (undefined = unverändert). // Leerstring = "auf null setzen" - hier setzen wir explizit auf null, // damit Prisma nicht den alten Wert hält. - const updateData: typeof data = { ...data }; + const updateData: typeof data = stripProviderStrings(data); if (data.portalUrl !== undefined) { const validated = assertValidPortalUrl(data.portalUrl); (updateData as { portalUrl: string | null }).portalUrl = validated ?? null; diff --git a/frontend/src/pages/settings/ProviderList.tsx b/frontend/src/pages/settings/ProviderList.tsx index 6b584148..0375c451 100644 --- a/frontend/src/pages/settings/ProviderList.tsx +++ b/frontend/src/pages/settings/ProviderList.tsx @@ -293,7 +293,21 @@ function ProviderModal({ usernameFieldName: '', passwordFieldName: '', isActive: true, + // Pentest 47.1: bei Portal-URL-Domain-Wechsel muss der aufrufende + // Admin sein eigenes Passwort mitsenden – Schutz gegen kompromittierten + // JWT, der sonst Phishing-URLs auf existierende Anbieter setzen könnte. + currentPassword: '', }); + const originalPortalUrl = provider?.portalUrl ?? ''; + const hostOf = (u: string) => { + try { return new URL(u.trim()).host.toLowerCase(); } catch { return ''; } + }; + const portalUrlHostChanged = + formData.portalUrl.trim() !== originalPortalUrl.trim() + && (hostOf(formData.portalUrl) || hostOf(originalPortalUrl)) + && hostOf(formData.portalUrl) !== hostOf(originalPortalUrl); + const portalUrlSetOnCreate = !provider && !!formData.portalUrl.trim(); + const needsReAuth = portalUrlHostChanged || portalUrlSetOnCreate; useEffect(() => { if (isOpen) { @@ -304,6 +318,7 @@ function ProviderModal({ usernameFieldName: provider.usernameFieldName || '', passwordFieldName: provider.passwordFieldName || '', isActive: provider.isActive, + currentPassword: '', }); } else { setFormData({ @@ -312,6 +327,7 @@ function ProviderModal({ usernameFieldName: '', passwordFieldName: '', isActive: true, + currentPassword: '', }); } } @@ -342,10 +358,17 @@ function ProviderModal({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (needsReAuth && !formData.currentPassword) { + alert('Bitte das eigene Passwort zur Bestätigung der Portal-URL eingeben.'); + return; + } + // currentPassword wird nur mitgesendet wenn überhaupt nötig + const payload: any = { ...formData }; + if (!needsReAuth) delete payload.currentPassword; if (provider) { - updateMutation.mutate(formData); + updateMutation.mutate(payload); } else { - createMutation.mutate(formData); + createMutation.mutate(payload); } }; @@ -373,6 +396,26 @@ function ProviderModal({ placeholder="https://kundenportal.anbieter.de/login" /> + {needsReAuth && ( +
+

+ Bestätigung erforderlich:{' '} + {portalUrlHostChanged + ? 'Die Portal-URL-Domain wurde geändert. Diese URL ist anschließend für alle Portal-Kunden dieses Anbieters klickbar.' + : 'Mit dem Speichern wird die Portal-URL für alle Portal-Kunden dieses Anbieters klickbar.'} + {' '}Zur Sicherheit ist eine Bestätigung mit dem eigenen Passwort nötig. +

+ setFormData({ ...formData, currentPassword: e.target.value })} + required + autoComplete="current-password" + /> +
+ )} +

Auto-Login Felder (optional)
diff --git a/frontend/src/pages/users/UserList.tsx b/frontend/src/pages/users/UserList.tsx index 0475a312..10e6f945 100644 --- a/frontend/src/pages/users/UserList.tsx +++ b/frontend/src/pages/users/UserList.tsx @@ -238,6 +238,9 @@ function UserModal({ const [formData, setFormData] = useState({ email: '', password: '', + // Pentest 47.3: bei Passwort-Änderung muss der aufrufende Admin sein + // eigenes Passwort zur Bestätigung mitsenden (Re-Auth gegen Token-Klau). + currentPassword: '', firstName: '', lastName: '', roleIds: [] as number[], @@ -257,6 +260,7 @@ function UserModal({ setFormData({ email: user.email, password: '', + currentPassword: '', firstName: user.firstName, lastName: user.lastName, roleIds: user.roles?.filter((r: any) => !['Developer', 'Kunde', 'DSGVO'].includes(r.name)).map((r: any) => r.id) || [], @@ -271,6 +275,7 @@ function UserModal({ setFormData({ email: '', password: '', + currentPassword: '', firstName: '', lastName: '', roleIds: [], @@ -326,9 +331,13 @@ function UserModal({ // Passwort-Setzen ist serverseitig ein eigener Endpoint (separater // Audit-Eintrag). Wenn beides gefragt: erst Daten, dann PW. if (formData.password) { + if (!formData.currentPassword) { + setError('Bitte das eigene Passwort zur Bestätigung eingeben.'); + return; + } updateMutation.mutate(updateData, { onSuccess: () => { - userApi.setPassword(user.id, formData.password).catch((err) => { + userApi.setPassword(user.id, formData.password, formData.currentPassword).catch((err) => { alert(err?.response?.data?.error || 'Passwort konnte nicht gesetzt werden'); }); }, @@ -412,6 +421,23 @@ function UserModal({

+ {user && formData.password && ( +
+ setFormData({ ...formData, currentPassword: e.target.value })} + required + autoComplete="current-password" + /> +

+ Sicherheitsmaßnahme: bestätige mit deinem eigenen Login-Passwort, + dass diese Änderung wirklich von dir kommt. +

+
+ )} +
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 0f8db684..fb85deac 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1344,8 +1344,9 @@ export const userApi = { }, // Passwort eines Users zurücksetzen (Admin-Funktion). Separat vom generischen // Update, damit der Vorgang einen eigenen Audit-Eintrag bekommt. - setPassword: async (id: number, password: string) => { - const res = await api.post>(`/users/${id}/password`, { password }); + // Pentest 47.3: braucht currentPassword (eigenes Admin-Passwort) als Re-Auth. + setPassword: async (id: number, password: string, currentPassword: string) => { + const res = await api.post>(`/users/${id}/password`, { password, currentPassword }); return res.data; }, delete: async (id: number) => {