Files
opencrm/backend/src/services/fileDownload.service.ts
T
duffyduck 6b804cdc82 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>
2026-05-01 07:59:19 +02:00

127 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}