Pentest Runde 35 follow-up: portalLoginUrl blockt ALLE privaten IPs

Runde-35-Befund: 34.5 nur teilweise gefixt – Cloud-Metadata
(169.254.x.x) wurde blockiert, aber 10/8, 172.16/12, 192.168/16,
127/8 und localhost gingen weiter durch, weil isBlockedSsrfHost
diese Ranges nur mit SSRF_BLOCK_PRIVATE_IPS=true geprüft hat. Der
Flag steht aber bewusst auf false für on-prem (Plesk auf 127.0.0.1).

Threat-Modell-Unterschied: portalLoginUrl ist eine URL in
*Endkunden-Mails*. Kunden können 127.0.0.1/192.168.x.x ohnehin nicht
erreichen → kein legitimer Wert. Daher muss der Check hier strikt
sein, unabhängig vom on-prem-Flag (der gilt nur für ausgehende
Server-zu-Server-Verbindungen wie Provider-Test-Connection).

Neuer isPrivateOrBlockedHost() in ssrfGuard.ts: union aus
BLOCKED_PATTERNS (Metadata/Multicast/Reserved) und
PRIVATE_IP_PATTERNS (10/8, 172.16/12, 192.168/16, 127/8, ::1,
fc00::/7) + PRIVATE_HOSTNAMES (localhost, ip6-loopback), egal was
SSRF_BLOCK_PRIVATE_IPS sagt.

portalLoginUrl-Validator nutzt jetzt isPrivateOrBlockedHost +
strippt eckige Klammern aus IPv6-Hostnames (Node URL.hostname
liefert "[::1]" inkl. Brackets).

Live-verifiziert: 22 Test-Cases (9 Private/Loopback, 4 Schemes,
7 legitime). Auch CIDR-Grenzen (172.15 zulässig, 172.16/31
blockiert, 172.32 zulässig).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 21:55:44 +02:00
parent 100147107c
commit 7dcdf9d6ef
2 changed files with 38 additions and 5 deletions
+13 -5
View File
@@ -1,6 +1,6 @@
import prisma from '../lib/prisma.js';
import { stripHtml } from '../utils/sanitize.js';
import { isBlockedSsrfHost } from '../utils/ssrfGuard.js';
import { isPrivateOrBlockedHost } from '../utils/ssrfGuard.js';
// Default settings
const DEFAULT_SETTINGS: Record<string, string> = {
@@ -115,8 +115,12 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
}
// Portal-Login-URL: nur http/https, keine relativen URLs, keine
// Cloud-Metadata/Link-Local/Mcast (ssrfGuard); private Ranges
// nur wenn SSRF_BLOCK_PRIVATE_IPS=true. Trailing-Slash strippen.
// 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.
if (key === 'portalLoginUrl') {
const trimmed = rawValue.trim().replace(/\/+$/, '');
if (trimmed === '') return { ok: true, value: '' };
@@ -132,8 +136,12 @@ export function validateSettingValue(key: string, rawValue: string): { ok: true;
if (!parsed.hostname) {
return { ok: false, error: 'Portal-Login-URL: Host fehlt.' };
}
if (isBlockedSsrfHost(parsed.hostname)) {
return { ok: false, error: `Portal-Login-URL: Host '${parsed.hostname}' ist gesperrt (interne/Metadata-Adresse).` };
// 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.
+25
View File
@@ -78,6 +78,31 @@ export function isBlockedSsrfHost(host: string | null | undefined): boolean {
return false;
}
/**
* Strikter Check: blockt private/loopback IP-Ranges UNABHÄNGIG von
* `SSRF_BLOCK_PRIVATE_IPS`. Für Use Cases, in denen ein privater Host
* NIE legitim sein kann z.B. eine URL, die an Endkunden per Mail
* geht (der Kunde kann eh nicht auf 192.168.x.x routen). Pentest
* 2026-05-28 Runde 35.
*
* Liefert true auch für die regulären Block-Patterns (Cloud-Metadata
* etc.), sodass Caller nur eine Funktion aufrufen müssen.
*/
export function isPrivateOrBlockedHost(host: string | null | undefined): boolean {
if (!host) return false;
const h = host.trim().toLowerCase();
if (!h) return false;
if (BLOCKED_HOSTNAMES.has(h)) return true;
if (PRIVATE_HOSTNAMES.has(h)) return true;
for (const pattern of BLOCKED_PATTERNS) {
if (pattern.test(h)) return true;
}
for (const pattern of PRIVATE_IP_PATTERNS) {
if (pattern.test(h)) return true;
}
return false;
}
/**
* Wirft einen Fehler, wenn der Host für ausgehende Verbindungen blockiert ist.
* Caller sollte den Fehler in 400er Response umsetzen.