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:
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Response } from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { AuthRequest } from '../types/index.js';
|
||||
import { findUploadOwner } from '../services/fileDownload.service.js';
|
||||
import { canAccessCustomer, canAccessContract } from '../utils/accessControl.js';
|
||||
|
||||
/**
|
||||
* Authentifizierter Download-Endpoint mit Per-File-Ownership-Check.
|
||||
* Ersetzt das ungeschützte `express.static('/api/uploads')`.
|
||||
*
|
||||
* Aufruf: GET /api/files/download?path=/uploads/<subDir>/<filename>
|
||||
*
|
||||
* Schritte:
|
||||
* 1. Pfad-Format prüfen (muss mit /uploads/ beginnen, kein Traversal)
|
||||
* 2. Owner via DB-Lookup ermitteln (welcher Customer/Contract gehört dazu?)
|
||||
* 3. canAccessCustomer / canAccessContract / Permission-Check
|
||||
* 4. Datei senden (mit korrektem Content-Type)
|
||||
*
|
||||
* Sicherheitsgewinn ggü. dem alten static-Handler: ein eingeloggter
|
||||
* Portal-Kunde kann jetzt nur seine eigenen Files (oder die seiner
|
||||
* vertretenen Kunden mit Vollmacht) herunterladen – nicht mehr beliebige
|
||||
* Pfade von fremden Kunden, selbst wenn er die Filenames irgendwo
|
||||
* mitgeschnitten hätte.
|
||||
*/
|
||||
export async function downloadFile(req: AuthRequest, res: Response): Promise<void> {
|
||||
const requested = typeof req.query.path === 'string' ? req.query.path : '';
|
||||
if (!requested) {
|
||||
res.status(400).json({ success: false, error: 'path-Parameter fehlt' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Format-Validierung (Traversal-Schutz)
|
||||
if (!requested.startsWith('/uploads/') || requested.includes('..') || requested.includes('\0')) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Owner ermitteln
|
||||
const owner = await findUploadOwner(requested);
|
||||
if (!owner) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Access-Check je nach Owner-Typ
|
||||
if (owner.kind === 'customer') {
|
||||
if (!(await canAccessCustomer(req, res, owner.customerId))) return;
|
||||
} else if (owner.kind === 'contract') {
|
||||
if (!(await canAccessContract(req, res, owner.contractId))) return;
|
||||
} else if (owner.kind === 'admin') {
|
||||
// PDF-Vorlagen: nur Mitarbeiter mit settings:read
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('settings:read') && !perms.includes('settings:update')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
} else if (owner.kind === 'gdpr-admin') {
|
||||
const perms = req.user?.permissions || [];
|
||||
if (!perms.includes('gdpr:admin')) {
|
||||
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Datei vom Disk lesen
|
||||
// requested startet mit /uploads/, wir mappen das auf process.cwd()/uploads/...
|
||||
const relative = requested.substring('/uploads/'.length);
|
||||
const absolute = path.join(process.cwd(), 'uploads', relative);
|
||||
// Letzter Pfad-Sicherheitscheck: absolute Path muss noch unter uploads/ liegen.
|
||||
const uploadsRoot = path.join(process.cwd(), 'uploads') + path.sep;
|
||||
if (!absolute.startsWith(uploadsRoot)) {
|
||||
res.status(400).json({ success: false, error: 'Ungültiger Pfad' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(absolute)) {
|
||||
res.status(404).json({ success: false, error: 'Datei nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Content-Type aus Extension bestimmen (konservativ – Express macht das eh)
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.sendFile(absolute);
|
||||
}
|
||||
Reference in New Issue
Block a user