6b804cdc82
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>
127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|