/** * Schutz vor Server-Side Request Forgery (SSRF) bei User-kontrollierten * Hosts/URLs in Endpunkten wie test-connection, test-mail-access. * * Wir blockieren bewusst NICHT die komplette private IP-Range (127.0.0.0/8, * 10.0.0.0/8 etc.), weil legitime On-Premise-Setups häufig Plesk/Dovecot/ * Postfix auf 127.0.0.1 oder im internen Netz laufen lassen. Stattdessen * blockieren wir nur: * - Cloud-Metadata-Endpoints (169.254.169.254, fd00:ec2::254) * - 169.254.0.0/16 Link-Local (deckt Cloud-Metadata + APIPA ab) * - 0.0.0.0/8 (ungültiger Source/Routing-Range) * - Multicast / Reserved Ranges (224.0.0.0/4, 240.0.0.0/4) * * Für Defense-in-Depth gegen DNS-Rebinding wäre eine vollständige DNS- * Resolution + IP-Vergleich nötig – das überlassen wir v1.1, weil es * legitimes Caching/CDN-Verhalten brechen kann. */ const BLOCKED_PATTERNS: RegExp[] = [ /^169\.254\./, // Link-Local (AWS/GCP/Azure Metadata, APIPA) /^0\./, // 0.0.0.0/8 reserved /^22[4-9]\./, // 224-229 Multicast /^23[0-9]\./, // 230-239 Multicast /^24[0-9]\./, // 240-249 reserved /^25[0-5]\./, // 250-255 reserved /^fd00:ec2::/i, // AWS IPv6 Metadata /^fe80:/i, // IPv6 Link-Local /^ff/i, // IPv6 Multicast ]; // Opt-in für Cloud-Deployments: ALLE privaten IP-Ranges blocken, nicht // nur Cloud-Metadata. On-Prem-Default ist `false`, weil On-Prem-Setups // häufig Plesk/Dovecot/Postfix auf 127.0.0.1 oder im internen Netz // laufen lassen. (Pentest 2026-05-20 INFO 30.14.) Aktivieren mit // `SSRF_BLOCK_PRIVATE_IPS=true` in der Umgebung. const BLOCK_PRIVATE_IPS = (process.env.SSRF_BLOCK_PRIVATE_IPS || '').toLowerCase() === 'true'; const PRIVATE_IP_PATTERNS: RegExp[] = [ /^127\./, // 127.0.0.0/8 Loopback /^10\./, // 10.0.0.0/8 /^192\.168\./, // 192.168.0.0/16 /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 /^::1$/, // IPv6 Loopback /^::ffff:127\./i, // IPv4-mapped Loopback /^::ffff:10\./i, // IPv4-mapped 10/8 /^::ffff:192\.168\./i, // IPv4-mapped 192.168/16 /^::ffff:172\.(1[6-9]|2\d|3[01])\./i, /^f[cd]/i, // fc00::/7 Unique-Local ]; const PRIVATE_HOSTNAMES = new Set([ 'localhost', 'ip6-localhost', 'ip6-loopback', ]); const BLOCKED_HOSTNAMES = new Set([ 'metadata.google.internal', 'metadata.goog', 'metadata', '169.254.169.254', ]); export function isBlockedSsrfHost(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; for (const pattern of BLOCKED_PATTERNS) { if (pattern.test(h)) return true; } if (BLOCK_PRIVATE_IPS) { if (PRIVATE_HOSTNAMES.has(h)) return true; for (const pattern of PRIVATE_IP_PATTERNS) { if (pattern.test(h)) return true; } } 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. */ export function assertAllowedHost(host: string | null | undefined, label = 'Host'): void { if (isBlockedSsrfHost(host)) { throw new Error(`${label} verweist auf eine geblockte Adresse (Cloud-Metadata / Link-Local / Reserved).`); } } import { promises as dns } from 'dns'; import net from 'net'; /** * DNS-Rebinding-Schutz: löst den Hostname zu allen IPs auf und prüft jede * gegen die Block-Liste. Wirft wenn IRGENDEINE IP geblockt ist. * * Das Resultat enthält die erste (geprüfte) IP plus den Original-Hostname * als `servername` für TLS-SNI / Cert-Validation. Der Caller muss die * Connection mit `host=ip` und `tls.servername=hostname` aufbauen, damit * ein zweiter DNS-Lookup keine andere (geblockte) IP liefern kann. * * Wenn der Host bereits eine IP-Literal ist, wird er direkt geprüft. */ export async function safeResolveHost(host: string | null | undefined, label = 'Host'): Promise<{ ip: string; servername: string }> { if (!host || !host.trim()) { throw new Error(`${label} fehlt`); } const trimmed = host.trim(); // IP-Literal? Direkt prüfen, kein DNS nötig. if (net.isIP(trimmed)) { assertAllowedHost(trimmed, label); return { ip: trimmed, servername: trimmed }; } // Hostname → resolve to IPv4 + IPv6 let ips: string[] = []; try { const v4 = await dns.resolve4(trimmed).catch(() => [] as string[]); const v6 = await dns.resolve6(trimmed).catch(() => [] as string[]); ips = [...v4, ...v6]; } catch { throw new Error(`${label}: DNS-Auflösung fehlgeschlagen für ${trimmed}`); } if (ips.length === 0) { throw new Error(`${label}: keine IP-Adresse für ${trimmed} gefunden`); } // Alle aufgelösten IPs prüfen – schon eine geblockte reicht für Ablehnung. for (const ip of ips) { if (isBlockedSsrfHost(ip)) { throw new Error(`${label} ${trimmed} löst auf geblockte Adresse ${ip} auf`); } } return { ip: ips[0], servername: trimmed }; }