7dcdf9d6ef
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>
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|