From 7dcdf9d6efbb619bab10a3441fe6fe2c8554d335 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 28 May 2026 21:55:44 +0200 Subject: [PATCH] Pentest Runde 35 follow-up: portalLoginUrl blockt ALLE privaten IPs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/services/appSetting.service.ts | 18 +++++++++++----- backend/src/utils/ssrfGuard.ts | 25 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/backend/src/services/appSetting.service.ts b/backend/src/services/appSetting.service.ts index 754bc44f..cd07b865 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 { isBlockedSsrfHost } from '../utils/ssrfGuard.js'; +import { isPrivateOrBlockedHost } from '../utils/ssrfGuard.js'; // Default settings const DEFAULT_SETTINGS: Record = { @@ -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. diff --git a/backend/src/utils/ssrfGuard.ts b/backend/src/utils/ssrfGuard.ts index 5df9b2d3..55f830e2 100644 --- a/backend/src/utils/ssrfGuard.ts +++ b/backend/src/utils/ssrfGuard.ts @@ -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.