Pentest 46.1 HIGH + Info-Konsolidierung: zentrale URL-Validierung

46.1 HIGH (Stored XSS via provider.portalUrl): PUT /api/providers/:id
nahm `javascript:alert(...)` als portalUrl ohne Validierung an, das
Portal rendert es als <a href={portalUrl}> → Klick im Kunden-Browser
löste XSS aus.

Fix: neuer zentraler Helper backend/utils/url.validateHttpUrl
- erlaubt nur http(s)-Schemas (sperrt javascript:, data:, file:,
  vbscript:, blob: usw.)
- erfordert absoluten URL mit Host
- per Default keine privaten/Loopback-Hosts (über
  isPrivateOrBlockedHost), weil der Wert Endkunden gezeigt wird
- Trailing-Slash wird gestrippt

Eingebaut in:
- provider.service createProvider + updateProvider (HIGH-Fix)
- appSetting.service validateSettingValue für portalLoginUrl
  (Refactor der bestehenden ad-hoc Validierung → konsolidiert)

Defense-in-depth Frontend: frontend/utils/url.safeHttpUrl liefert
URLs nur zurück wenn http(s), sonst undefined. Eingesetzt in
ContractDetail bei Portal-Link-Rendering und Auto-Login, damit
Alt-Daten in der DB (vor diesem Fix angelegt) nicht klickbar
bleiben.

INFO-Konsolidierung: damit ist die Schema-/Host-Validierung
einheitlich an einer Stelle. Sanitize-Layer (stripHtml in
sanitize.ts) bleibt für reine Text-Felder zuständig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 11:48:14 +02:00
parent c58a60db23
commit d0d2715baa
5 changed files with 147 additions and 54 deletions
+9 -32
View File
@@ -1,6 +1,6 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { isPrivateOrBlockedHost } from '../utils/ssrfGuard.js';
import { validateHttpUrl } from '../utils/url.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -114,38 +114,15 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
return { ok: true, value: trimmed };
}
// Portal-Login-URL: nur http/https, keine relativen URLs, keine
// privaten oder loopback-Hosts. Strikter als `isBlockedSsrfHost`,
// weil der Wert in Mails an Endkunden landet die können
// 127.0.0.1/10.x/172.16.x/192.168.x ohnehin nicht erreichen,
// also gibt's keinen legitimen Grund für so eine URL hier.
// (Pentest Runde 35 LOW 34.5-followup, on-prem-Override SSRF_BLOCK_
// PRIVATE_IPS gilt hier explizit NICHT.) Trailing-Slash wird gestrippt.
// Portal-Login-URL: nur http/https, keine privaten/loopback-Hosts.
// Strikter als `isBlockedSsrfHost`, weil der Wert in Mails an Endkunden
// landet die können 127.0.0.1/10.x/172.16.x/192.168.x ohnehin nicht
// erreichen. (Pentest Runde 35 LOW 34.5-followup, on-prem-Override
// SSRF_BLOCK_PRIVATE_IPS gilt hier explizit NICHT.)
// Werte mit Pfad/Query sind erlaubt Mail-Versand hängt ohnehin
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
if (key === 'portalLoginUrl') {
const trimmed = rawValue.trim().replace(/\/+$/, '');
if (trimmed === '') return { ok: true, value: '' };
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return { ok: false, error: 'Portal-Login-URL muss eine absolute http(s)-URL sein.' };
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: `Portal-Login-URL: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` };
}
if (!parsed.hostname) {
return { ok: false, error: 'Portal-Login-URL: Host fehlt.' };
}
// Node's URL-Parser lässt eckige Klammern im hostname für IPv6
// (`http://[::1]` → hostname `"[::1]"`). Klammern strippen, sonst
// matcht der Loopback-Pattern `^::1$` nicht.
const hostForCheck = parsed.hostname.replace(/^\[|\]$/g, '');
if (isPrivateOrBlockedHost(hostForCheck)) {
return { ok: false, error: `Portal-Login-URL: Host '${hostForCheck}' ist gesperrt (interne, private oder Loopback-Adresse). Bitte öffentlich erreichbare Domain verwenden.` };
}
// Werte mit Pfad/Query sind erlaubt Mail-Versand hängt ohnehin
// /portal/login hinten dran, eine evtl. Pfad-Komponente bleibt.
return { ok: true, value: trimmed };
return validateHttpUrl(rawValue, { fieldLabel: 'Portal-Login-URL' });
}
// Default: kein zusätzlicher Format-Check
+26 -1
View File
@@ -1,4 +1,19 @@
import prisma from '../lib/prisma.js';
import { validateHttpUrl } from '../utils/url.js';
// Pentest 46.1 (HIGH, 2026-06-01): Stored XSS via provider.portalUrl.
// PUT akzeptierte `javascript:alert(...)` als URL, das Portal rendert
// sie als <a href={portalUrl}> → ein Klick im Kunden-Browser löst die
// XSS aus. Fix: vor Schreiben durch validateHttpUrl, das auch andere
// gefährliche Schemata (data:, vbscript:, file:) sperrt und private
// IPs verbietet (die URL wird Kunden gezeigt, denen interne Hosts
// nichts bringen).
function assertValidPortalUrl(portalUrl: string | undefined | null): string | undefined {
if (portalUrl == null || portalUrl === '') return undefined;
const check = validateHttpUrl(portalUrl, { fieldLabel: 'Portal-URL' });
if (!check.ok) throw new Error(check.error);
return check.value === '' ? undefined : check.value;
}
export async function getAllProviders(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
@@ -37,9 +52,11 @@ export async function createProvider(data: {
usernameFieldName?: string;
passwordFieldName?: string;
}) {
const portalUrl = assertValidPortalUrl(data.portalUrl);
return prisma.provider.create({
data: {
...data,
portalUrl,
isActive: true,
},
});
@@ -55,9 +72,17 @@ export async function updateProvider(
isActive?: boolean;
}
) {
// 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 };
if (data.portalUrl !== undefined) {
const validated = assertValidPortalUrl(data.portalUrl);
(updateData as { portalUrl: string | null }).portalUrl = validated ?? null;
}
return prisma.provider.update({
where: { id },
data,
data: updateData,
});
}