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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user