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
+30 -21
View File
@@ -21,6 +21,7 @@ import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
import { fileUrl, viewUrl } from '../../utils/fileUrl';
import { safeHttpUrl } from '../../utils/url';
const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom',
@@ -1694,7 +1695,9 @@ export default function ContractDetail() {
const contract = data?.data;
// Get username from stressfreiEmail or portalUsername
const username = contract?.stressfreiEmail?.email || contract?.portalUsername;
if (!contract?.provider?.portalUrl || !username) {
// Defense-in-depth: nur http(s) verhindert `javascript:`-URLs aus Alt-Daten.
const safeUrl = safeHttpUrl(contract?.provider?.portalUrl);
if (!safeUrl || !username) {
alert('Portal-URL oder Benutzername fehlt');
return;
}
@@ -1708,8 +1711,8 @@ export default function ContractDetail() {
return;
}
const provider = contract.provider;
const baseUrl = provider.portalUrl!; // Already validated above
const provider = contract!.provider!;
const baseUrl = safeUrl;
const usernameField = provider.usernameFieldName || 'username';
const passwordField = provider.passwordFieldName || 'password';
@@ -2469,24 +2472,30 @@ export default function ContractDetail() {
</dd>
</div>
)}
{c.provider?.portalUrl && (
<div>
<dt className="text-sm text-gray-500">Portal-Link</dt>
<dd className="flex items-center gap-1">
<a
href={c.provider.portalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline truncate"
title={c.provider.portalUrl}
>
{c.provider.portalUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}
<ExternalLink className="w-3 h-3 inline ml-1 -mt-0.5" />
</a>
<CopyButton value={c.provider.portalUrl} />
</dd>
</div>
)}
{(() => {
// Defense-in-depth: nur http(s) als href erlauben,
// damit Alt-Daten mit `javascript:` nicht klickbar werden.
const safePortalUrl = safeHttpUrl(c.provider?.portalUrl);
if (!safePortalUrl) return null;
return (
<div>
<dt className="text-sm text-gray-500">Portal-Link</dt>
<dd className="flex items-center gap-1">
<a
href={safePortalUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline truncate"
title={safePortalUrl}
>
{safePortalUrl.replace(/^https?:\/\//, '').replace(/\/$/, '')}
<ExternalLink className="w-3 h-3 inline ml-1 -mt-0.5" />
</a>
<CopyButton value={safePortalUrl} />
</dd>
</div>
);
})()}
{c.hasPortalPassword && (
<div>
<dt className="text-sm text-gray-500">Passwort</dt>
+24
View File
@@ -0,0 +1,24 @@
/**
* Defense-in-depth: liefert einen URL-String nur zurück, wenn er ein
* sicheres http(s)-Schema hat. Sonst undefined.
*
* Hintergrund: das Backend validiert beim Speichern (Pentest 46.1),
* aber Alt-Daten in der DB können noch `javascript:alert(...)` o.ä.
* enthalten. React eskapt URLs in `href` NICHT automatisch ein Klick
* auf einen `javascript:`-Link triggert die XSS im User-Browser.
*
* Diese Funktion wird überall dort eingesetzt, wo wir User-Input
* als `<a href>` rendern oder per `window.open` öffnen.
*/
export function safeHttpUrl(value: string | null | undefined): string | undefined {
if (!value) return undefined;
const trimmed = value.trim();
if (trimmed === '') return undefined;
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined;
return trimmed;
} catch {
return undefined;
}
}