Pentest 47.1/47.2/47.3: Re-Auth bei sensiblen Operationen + Provider.name-Strip

47.3 MEDIUM (Admin-Passwort-Reset ohne Re-Auth):
POST /api/users/:id/password verlangt jetzt currentPassword im
Body. Backend prüft per bcrypt.compare gegen den Hash des
aufrufenden Admins. Frontend (UserList-Modal): zusätzliches
Passwort-Feld wird eingeblendet, sobald für einen User ein neues
Passwort gesetzt werden soll. Gestohlener JWT allein reicht damit
nicht mehr.

47.1 MEDIUM (Open Redirect / Phishing via provider.portalUrl):
Selbes Re-Auth-Pattern für Provider-Endpoints. Nur wenn die
Portal-URL-Domain WIRKLICH gewechselt wird (Host-Vergleich)
oder beim Create mit URL, ist currentPassword Pflicht. Reine
Namens-/Tarif-Edits bleiben friction-frei.
Audit-Log bekommt die Portal-URL beim Ändern explizit mitgeloggt
(Forensik bei Vorfällen). Frontend ProviderModal zeigt amber-
farbenen Bestätigungs-Banner mit Passwort-Eingabe sobald der
Host wechselt.

47.2 INFO (provider.name ohne Backend-Sanitization):
Neuer Helper stripProviderStrings in provider.service, wendet
stripHtml auf name + usernameFieldName + passwordFieldName an –
Defense-in-Depth gegen neue Renderpfade (PDF, Mail-Templates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:38:45 +02:00
parent d0d2715baa
commit 2c0166ed99
6 changed files with 193 additions and 16 deletions
+45 -2
View File
@@ -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 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
<p className="text-sm text-amber-800">
<strong>Bestätigung erforderlich:</strong>{' '}
{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.
</p>
<Input
label="Eigenes Passwort zur Bestätigung *"
type="password"
value={formData.currentPassword}
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
required
autoComplete="current-password"
/>
</div>
)}
<div className="p-3 bg-gray-50 rounded-lg space-y-3">
<p className="text-sm text-gray-600">
<strong>Auto-Login Felder</strong> (optional)<br />
+27 -1
View File
@@ -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({
</p>
</div>
{user && formData.password && (
<div>
<Input
label="Eigenes Passwort zur Bestätigung *"
type="password"
value={formData.currentPassword}
onChange={(e) => setFormData({ ...formData, currentPassword: e.target.value })}
required
autoComplete="current-password"
/>
<p className="text-xs text-gray-500 mt-1">
Sicherheitsmaßnahme: bestätige mit deinem eigenen Login-Passwort,
dass diese Änderung wirklich von dir kommt.
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Messaging-Kanäle (für Datenschutz-Versand)</label>
<div className="space-y-3">
+3 -2
View File
@@ -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<ApiResponse<void>>(`/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<ApiResponse<void>>(`/users/${id}/password`, { password, currentPassword });
return res.data;
},
delete: async (id: number) => {