a95aa384a2
30.13 MIME-Extension-XSS (MEDIUM): GET /api/files/download lieferte hochgeladene Dateien via res.sendFile() aus. Da multer nur den client-gemeldeten MIME prueft, konnte eine als application/pdf deklarierte .html-Datei auf Disk landen – Express liest beim Senden den Content-Type aus der Extension (text/html), Browser haette gerendert → Stored XSS. Fix: Content-Disposition: attachment + safe filename. Browser laedt jetzt herunter statt zu rendern, egal welcher Content-Type. UX-Cost ist gering (PDF-Preview offnet halt aus dem Download-Ordner). X-Content-Type-Options: nosniff bleibt zusaetzlich gesetzt. 30.14 SSRF Private-IP-Block opt-in (INFO): ssrfGuard erlaubte private IPs (127/10/172.16/192.168) bewusst, weil On-Prem-Setups Plesk/Dovecot/Postfix lokal laufen lassen. Fuer Cloud-Deployments ist das ein SSRF-Vektor. Neuer Env-Flag SSRF_BLOCK_PRIVATE_IPS=true erweitert die Block-Liste um alle privaten Ranges + ::1 + fc00::/7 + IPv4-mapped + localhost/ ip6-localhost. Default off (on-prem-kompatibel). Live-verifiziert auf dev: - Download-Header: Content-Disposition: attachment + safe filename - Default: 127.0.0.1/10.x/192.168.x/localhost durchgelassen, 169.254.169.254 (Cloud-Metadata) weiter geblockt - SSRF_BLOCK_PRIVATE_IPS=true: alle privaten Ranges geblockt, 8.8.8.8 (legitim) durchgelassen Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
140 lines
5.1 KiB
TypeScript
140 lines
5.1 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;
|
||
}
|
||
|
||
/**
|
||
* 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 };
|
||
}
|