Security-Hardening Runde 8: DNS-Rebinding + Per-File-Ownership
Loose Ends aus Runde 5/7 abgearbeitet.
🛡 DNS-Rebinding-Schutz in SSRF-Guard
- safeResolveHost() löst Hostname zu IPv4+IPv6 auf, prüft jede IP
gegen die Block-Liste, gibt {ip, servername} zurück.
- Caller (test-connection, test-mail-access) übergibt host=ip plus
servername=hostname an die Mail-Services. Damit kann ein zweiter
DNS-Lookup zur Connection-Zeit nicht plötzlich auf interne IPs
umlenken (rebound-Attack).
- ImapCredentials/SmtpCredentials um optionales servername-Feld
erweitert; Services nutzen es als TLS-SNI / Cert-Validation-Hint.
🔒 Per-File-Ownership-Check (DSGVO-Härtung)
- express.static('/api/uploads') ersetzt durch GET /api/files/download
mit Pfad→Resource→Owner-Mapping in fileDownload.service.ts.
- 12 subDir-Mappings (bank-cards, documents, contract-documents,
invoices, cancellation-*, authorizations, business-/commercial-/
privacy-, pdf-templates).
- canAccessCustomer / canAccessContract / Permission-Check je nach
Owner-Typ. Portal-User sieht jetzt nur eigene Dateien, selbst wenn
er fremde Filenames kennt.
- Backwards-Compat: /api/uploads/* bleibt als Shim erhalten, ruft
intern denselben Owner-Check.
- Frontend fileUrl() zeigt auf /api/files/download?path=...&token=...
Live-verifiziert:
- Eigene Datei: 200, random Pfad: 404, ../etc/passwd: 400, kein
Token: 401, Backwards-Compat-Shim: 200.
- DNS-Rebinding: nip.io-Hostname mit interner Target-IP wird via
DNS-Lookup geblockt; gmail.com (legitim) geht durch.
Bewusst nicht gemacht:
- Signierte URLs mit kurzlebigen Download-Tokens – v1.2-Item, da
invasiv für <a href>-Flows ohne JS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -55,3 +55,53 @@ export function assertAllowedHost(host: string | null | undefined, label = '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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user