Pentest R138 Hygiene + neue Vorgaben: 1) endDate wird bei DRAFT-Vertraegen NICHT mehr gesetzt (Entwurf = nur Vorlage). Nur Status wurde vorher geschont, endDate zog trotzdem mit. 2) Ueberweisungsbelege (credit-note-receipts) sind jetzt reine Mitarbeiter/Admin-Downloads: neuer FileOwner-kind 'contract-staff' blockt Portal-Kunden im fileDownload-Controller. Das generierte Gutschrift-PDF (credit-notes) bleibt vertragsbasiert -> der besitzende Kunde darf seine eigene Gutschrift laden. Bereits vorher abgesichert (bestaetigt): Kunden koennen keine Gutschriften anlegen (blockPortal) und keine Belege hochladen (Portal-403 im Upload). Verifiziert: DRAFT haelt endDate; Beleg-Owner=contract-staff, PDF-Owner=contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 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 }
|
||
// Wie 'contract', aber Portal-Kunden ausgeschlossen (nur Mitarbeiter/Admin).
|
||
| { kind: 'contract-staff'; 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 'credit-note-receipts': {
|
||
// Überweisungsbeleg: NUR Mitarbeiter/Admin. Portal-Kunden dürfen Belege
|
||
// nicht herunterladen (nur ihre Gutschrift selbst).
|
||
const r = await prisma.creditNote.findFirst({
|
||
where: { receiptPath: uploadPath },
|
||
select: { contractId: true },
|
||
});
|
||
return r ? { kind: 'contract-staff', contractId: r.contractId } : null;
|
||
}
|
||
|
||
case 'credit-notes': {
|
||
// Generiertes Gutschrift-PDF: Owner ist der Vertrag der Gutschrift –
|
||
// der besitzende Kunde darf seine eigene Gutschrift laden.
|
||
const r = await prisma.creditNote.findFirst({
|
||
where: { pdfPath: uploadPath },
|
||
select: { contractId: true },
|
||
});
|
||
return r ? { 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;
|
||
}
|
||
}
|