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:
2026-05-01 07:59:19 +02:00
co-authored by Claude Opus 4.7
parent df6eb9724d
commit 6b804cdc82
9 changed files with 372 additions and 29 deletions
@@ -7,7 +7,7 @@ import { ApiResponse } from '../types/index.js';
import { testImapConnection, ImapCredentials } from '../services/imapService.js';
import { testSmtpConnection, SmtpCredentials } from '../services/smtpService.js';
import { decrypt } from '../utils/encryption.js';
import { assertAllowedHost } from '../utils/ssrfGuard.js';
import { assertAllowedHost, safeResolveHost } from '../utils/ssrfGuard.js';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@@ -119,13 +119,15 @@ export async function testConnection(req: Request, res: Response): Promise<void>
domain: req.body.domain,
} : undefined;
// SSRF-Guard: testData.apiUrl-Hostname prüfen
// SSRF-Guard inkl. DNS-Rebinding: testData.apiUrl-Hostname zu IP auflösen
// und prüfen. Wenn DNS auf eine geblockte IP zeigt, abbrechen ohne dass
// ein zweiter Lookup zur Connection-Zeit eine andere IP liefern könnte.
if (testData?.apiUrl) {
try {
const url = new URL(testData.apiUrl);
assertAllowedHost(url.hostname, 'apiUrl-Host');
await safeResolveHost(url.hostname, 'apiUrl-Host');
} catch (err) {
if (err instanceof Error && err.message.includes('geblockte')) {
if (err instanceof Error && (err.message.includes('geblockte') || err.message.includes('DNS'))) {
res.status(400).json({ success: false, error: err.message } as ApiResponse);
return;
}
@@ -229,12 +231,17 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
return;
}
// SSRF-Guard: Wenn der Host vom Body kommt, blockieren wir Cloud-Metadata
// und Reserved-Ranges. Loopback/Private-Ranges bleiben erlaubt für
// legitime Plesk/Postfix-Setups.
// SSRF-Guard inkl. DNS-Rebinding: Hostnames pre-resolven und gegen
// geblockte IPs prüfen. Connection läuft danach gegen die IP, der
// ursprüngliche Hostname wird als TLS-servername gesetzt damit kann
// ein zweiter DNS-Lookup keine andere IP unterschieben.
let smtpResolved: { ip: string; servername: string };
let imapResolved: { ip: string; servername: string };
try {
assertAllowedHost(smtpServer, 'SMTP-Server');
assertAllowedHost(imapServer, 'IMAP-Server');
[smtpResolved, imapResolved] = await Promise.all([
safeResolveHost(smtpServer, 'SMTP-Server'),
safeResolveHost(imapServer, 'IMAP-Server'),
]);
} catch (err) {
res.status(400).json({
success: false,
@@ -245,22 +252,24 @@ export async function testMailAccess(req: Request, res: Response): Promise<void>
// IMAP testen
const imapCredentials: ImapCredentials = {
host: imapServer,
host: imapResolved.ip,
port: imapPort,
user: emailAddress,
password,
encryption: imapEncryption,
allowSelfSignedCerts,
servername: imapResolved.servername,
};
// SMTP testen
const smtpCredentials: SmtpCredentials = {
host: smtpServer,
host: smtpResolved.ip,
port: smtpPort,
user: emailAddress,
password,
encryption: smtpEncryption,
allowSelfSignedCerts,
servername: smtpResolved.servername,
};
let imapResult: { success: boolean; error?: string } = { success: false };