Security-Hardening Runde 7: SSRF-Schutz + Logout-Endpoint
🛡 SSRF-Schutz in test-connection / test-mail-access - Admin-User konnte über apiUrl bzw. SMTP/IMAP-Server-Felder Connections zu Cloud-Metadata-Endpoints (169.254.169.254, metadata.google.internal etc.) auslösen. Internal-Port-Scan über Timing-Differenzen war messbar. - Fix: neuer utils/ssrfGuard.ts blockiert pre-flight 169.254.0.0/16, 0.0.0.0/8, Multicast/Reserved-Ranges, AWS-IPv6-Metadata, IPv6-Link-Local und Cloud-Metadata-Hostnames. Loopback (127.0.0.0/8) bleibt erlaubt – legitime Plesk/Postfix- Setups sollen weiter funktionieren. 🔒 Logout-Endpoint POST /api/auth/logout - Setzt tokenInvalidatedAt / portalTokenInvalidatedAt auf jetzt. Auth-Middleware prüft das Feld bereits und lehnt Tokens mit iat davor ab. Ohne diesen Endpoint blieb ein "abgemeldeter" JWT bis Expiry (7d) gültig. Live-verifiziert: - 169.254.169.254 / metadata.google.internal / 0.0.0.0 → 400 - 127.0.0.1 (Plesk-Fall) weiter erlaubt - /me vor Logout 200, nach Logout 401 "Sitzung ungültig" Geprüft + sauber (Runde 7, kein Bug): - Public Consent (122-bit Random-UUID nicht brute-force-bar) - Magic-Bytes-Bypass beim Upload - PDF manualValues Injection (keine HTML-Render-Surface) - Query-Filter-Override (?customerId=X) – vom Portal-Filter ignoriert - Audit-Logs / Email-Config / Backup-Endpoints als Portal: 403 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 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
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
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).`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user