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:
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Pfad → Resource → Owner Mapping für `/api/files/download`.
|
||||
*
|
||||
* Jeder Upload-Subdirectory ist mit genau einem Prisma-Model + Path-Field
|
||||
* verknüpft. Wir suchen den Record, der diesen Path referenziert, und
|
||||
* leiten daraus den zuständigen Customer/Contract ab. canAccessCustomer /
|
||||
* canAccessContract entscheidet danach über Zugriff.
|
||||
*
|
||||
* Pfade werden 1:1 mit dem in der DB gespeicherten Wert verglichen
|
||||
* (z.B. `/uploads/bank-cards/12345.pdf`). Damit ist Path-Traversal
|
||||
* automatisch ausgeschlossen – ein konstruierter Pfad findet keinen Record.
|
||||
*/
|
||||
import prisma from '../lib/prisma.js';
|
||||
|
||||
export type FileOwner =
|
||||
| { kind: 'customer'; customerId: number }
|
||||
| { kind: 'contract'; contractId: number }
|
||||
| { kind: 'admin' }
|
||||
| { kind: 'gdpr-admin' };
|
||||
|
||||
export async function findUploadOwner(uploadPath: string): Promise<FileOwner | null> {
|
||||
// Format-Check: muss mit /uploads/<subDir>/<filename> beginnen, kein Traversal.
|
||||
if (!uploadPath.startsWith('/uploads/')) return null;
|
||||
if (uploadPath.includes('..') || uploadPath.includes('\0')) return null;
|
||||
|
||||
const parts = uploadPath.split('/');
|
||||
// ['', 'uploads', '<subDir>', '<filename...>']
|
||||
if (parts.length < 4) return null;
|
||||
const subDir = parts[2];
|
||||
|
||||
switch (subDir) {
|
||||
case 'bank-cards': {
|
||||
const r = await prisma.bankCard.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'documents': {
|
||||
const r = await prisma.identityDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'business-registrations': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { businessRegistrationPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'commercial-registers': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { commercialRegisterPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'privacy-policies': {
|
||||
const r = await prisma.customer.findFirst({
|
||||
where: { privacyPolicyPath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'authorizations': {
|
||||
const r = await prisma.representativeAuthorization.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { customerId: true },
|
||||
});
|
||||
return r ? { kind: 'customer', customerId: r.customerId } : null;
|
||||
}
|
||||
|
||||
case 'contract-documents': {
|
||||
const r = await prisma.contractDocument.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'invoices': {
|
||||
const r = await prisma.invoice.findFirst({
|
||||
where: { documentPath: uploadPath },
|
||||
select: { contractId: true },
|
||||
});
|
||||
return r?.contractId ? { kind: 'contract', contractId: r.contractId } : null;
|
||||
}
|
||||
|
||||
case 'cancellation-letters':
|
||||
case 'cancellation-confirmations':
|
||||
case 'cancellation-letters-options':
|
||||
case 'cancellation-confirmations-options': {
|
||||
const fieldMap: Record<string, 'cancellationLetterPath' | 'cancellationConfirmationPath' | 'cancellationLetterOptionsPath' | 'cancellationConfirmationOptionsPath'> = {
|
||||
'cancellation-letters': 'cancellationLetterPath',
|
||||
'cancellation-confirmations': 'cancellationConfirmationPath',
|
||||
'cancellation-letters-options': 'cancellationLetterOptionsPath',
|
||||
'cancellation-confirmations-options': 'cancellationConfirmationOptionsPath',
|
||||
};
|
||||
const field = fieldMap[subDir];
|
||||
const r = await prisma.contract.findFirst({
|
||||
where: { [field]: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'contract', contractId: r.id } : null;
|
||||
}
|
||||
|
||||
case 'pdf-templates': {
|
||||
// Admin-only Resource: Vorlagen gehören keinem Customer.
|
||||
const r = await prisma.pdfTemplate.findFirst({
|
||||
where: { templatePath: uploadPath },
|
||||
select: { id: true },
|
||||
});
|
||||
return r ? { kind: 'admin' } : null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export interface ImapCredentials {
|
||||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +32,12 @@ function buildTlsOptions(credentials: ImapCredentials): Record<string, unknown>
|
||||
const rejectUnauthorized = !credentials.allowSelfSignedCerts;
|
||||
const options: Record<string, unknown> = { rejectUnauthorized };
|
||||
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist und der ursprüngliche
|
||||
// Hostname als servername mitgeliefert wird, nutze ihn für SNI/Cert.
|
||||
if (credentials.servername) {
|
||||
options.servername = credentials.servername;
|
||||
}
|
||||
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
options.minVersion = 'TLSv1';
|
||||
options.ciphers = 'DEFAULT:@SECLEVEL=0';
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface SmtpCredentials {
|
||||
password: string;
|
||||
encryption?: MailEncryption; // SSL, STARTTLS oder NONE (Standard: SSL)
|
||||
allowSelfSignedCerts?: boolean; // Selbstsignierte Zertifikate erlauben
|
||||
// DNS-Rebinding-Schutz: Caller hat den Hostname zu IP aufgelöst und
|
||||
// setzt host=IP, servername=originalHostname für TLS-SNI / Cert-Validation.
|
||||
// Damit kann ein zweiter DNS-Lookup nicht plötzlich auf eine interne IP zeigen.
|
||||
servername?: string;
|
||||
}
|
||||
|
||||
// Anhang-Interface
|
||||
@@ -94,7 +98,7 @@ export async function sendEmail(
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
ignoreTLS?: boolean;
|
||||
requireTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
@@ -116,6 +120,11 @@ export async function sendEmail(
|
||||
// TLS-Optionen nur wenn nicht NONE
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
// DNS-Rebinding-Schutz: wenn host eine IP ist, der ursprüngliche
|
||||
// Hostname für SNI/Cert-Validation explizit setzen.
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
if (credentials.allowSelfSignedCerts) {
|
||||
// Auch ältere TLS-Versionen + legacy Cipher-Suites für alte Server zulassen
|
||||
transportOptions.tls.minVersion = 'TLSv1';
|
||||
@@ -303,7 +312,7 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: { user: string; pass: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string };
|
||||
tls?: { rejectUnauthorized: boolean; minVersion?: string; ciphers?: string; servername?: string };
|
||||
ignoreTLS?: boolean;
|
||||
connectionTimeout: number;
|
||||
greetingTimeout: number;
|
||||
@@ -321,6 +330,9 @@ export async function testSmtpConnection(credentials: SmtpCredentials): Promise<
|
||||
|
||||
if (encryption !== 'NONE') {
|
||||
transportOptions.tls = { rejectUnauthorized };
|
||||
if (credentials.servername) {
|
||||
transportOptions.tls.servername = credentials.servername;
|
||||
}
|
||||
} else {
|
||||
transportOptions.ignoreTLS = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user