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:
@@ -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 `<script>`-Tags
|
||||
*
|
||||
* Regelwerk:
|
||||
* - Leer/null → OK (Feld ist optional, keine Validierung)
|
||||
* - Schema MUSS http oder https sein (keine `javascript:`,
|
||||
* `data:`, `file:`, `vbscript:` …)
|
||||
* - Host muss vorhanden sein
|
||||
* - Bei `allowPrivateHosts=false` (Default): Private/Loopback-IPs
|
||||
* und Cloud-Metadata-Adressen sind gesperrt, weil die URL für
|
||||
* Endkunden gedacht ist und 10.x/192.168.x für die ohnehin
|
||||
* nicht erreichbar wären
|
||||
* - Trailing-Slash wird gestrippt (Komfort beim Speichern)
|
||||
*/
|
||||
export function validateHttpUrl(
|
||||
rawValue: string,
|
||||
opts: { fieldLabel?: string; allowPrivateHosts?: boolean } = {},
|
||||
): { ok: true; value: string } | { ok: false; error: string } {
|
||||
const label = opts.fieldLabel ?? 'URL';
|
||||
const trimmed = rawValue.trim().replace(/\/+$/, '');
|
||||
if (trimmed === '') return { ok: true, value: '' };
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(trimmed);
|
||||
} catch {
|
||||
return { ok: false, error: `${label} muss eine absolute http(s)-URL sein.` };
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return { ok: false, error: `${label}: unzulässiges Schema '${parsed.protocol}'. Nur http(s) erlaubt.` };
|
||||
}
|
||||
if (!parsed.hostname) {
|
||||
return { ok: false, error: `${label}: Host fehlt.` };
|
||||
}
|
||||
|
||||
if (!opts.allowPrivateHosts) {
|
||||
// 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: `${label}: Host '${hostForCheck}' ist gesperrt (interne, private oder Loopback-Adresse). Bitte öffentlich erreichbare Domain verwenden.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: trimmed };
|
||||
}
|
||||
Reference in New Issue
Block a user