Files
opencrm/backend/src/utils/ssrfGuard.ts
T
duffyduck a95aa384a2 Pentest 2026-05-20 Pen-30-Befunde (MEDIUM+INFO)
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>
2026-05-20 20:14:59 +02:00

140 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}