From d0d2715baac6c5d772b2e99053c04b49447b9553 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Mon, 1 Jun 2026 11:48:14 +0200 Subject: [PATCH] Pentest 46.1 HIGH + Info-Konsolidierung: zentrale URL-Validierung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 → 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) --- backend/src/services/appSetting.service.ts | 41 +++---------- backend/src/services/provider.service.ts | 27 ++++++++- backend/src/utils/url.ts | 58 +++++++++++++++++++ .../src/pages/contracts/ContractDetail.tsx | 51 +++++++++------- frontend/src/utils/url.ts | 24 ++++++++ 5 files changed, 147 insertions(+), 54 deletions(-) create mode 100644 backend/src/utils/url.ts create mode 100644 frontend/src/utils/url.ts diff --git a/backend/src/services/appSetting.service.ts b/backend/src/services/appSetting.service.ts index cd07b865..bbd1db03 100644 --- a/backend/src/services/appSetting.service.ts +++ b/backend/src/services/appSetting.service.ts @@ -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 = { @@ -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 diff --git a/backend/src/services/provider.service.ts b/backend/src/services/provider.service.ts index 315dac96..be4329c3 100644 --- a/backend/src/services/provider.service.ts +++ b/backend/src/services/provider.service.ts @@ -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 → 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, }); } diff --git a/backend/src/utils/url.ts b/backend/src/utils/url.ts new file mode 100644 index 00000000..9bbd2517 --- /dev/null +++ b/backend/src/utils/url.ts @@ -0,0 +1,58 @@ +import { isPrivateOrBlockedHost } from './ssrfGuard.js'; + +/** + * Zentrale Validierung für nach außen geleitete URLs (Portal-Links, + * Anbieter-Portale, Mail-Footer). Konsolidiert die Schema-/Host-Checks, + * die bisher pro Feld einzeln (und uneinheitlich) verstreut waren: + * - `appSetting.portalLoginUrl` hatte einen vollen Check + * - `provider.portalUrl` hatte gar keinen → Stored XSS via + * `javascript:alert(...)` (Pentest 46.1 HIGH) + * - andere Felder strippten nur `