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
+17 -3
View File
@@ -1,4 +1,5 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
@@ -46,16 +47,29 @@ export async function getProviderById(id: number) {
});
}
// Pentest 47.2 (INFO, 2026-06-01): provider.name landete roh in der DB.
// Aktuell escapt React das Textnode, also kein direkter XSS aber neue
// Renderpfade (PDF, Mail-Templates, Embedded-Strings in URLs) wären
// sofort betroffen. Defense-in-depth: schon beim Schreiben strippen.
function stripProviderStrings<T extends { name?: string; usernameFieldName?: string; passwordFieldName?: string }>(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;