Files
opencrm/backend/src/controllers/fileDownload.controller.ts
T
duffyduckandClaude Opus 4.8 0f51a1cc3b Gutschriften/Kuendigung: DRAFT-endDate-Schutz + Belege staff-only
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>
2026-08-08 21:29:43 +02:00

166 lines
6.9 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.
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 === 'contract-staff') {
// Nur Mitarbeiter/Admin Portal-Kunden nie (z.B. Gutschrift-
// Überweisungsbelege). Staff dürfen wie bei allen Vertrags-Files.
if (req.user?.isCustomerPortal) {
res.status(403).json({ success: false, error: 'Keine Berechtigung' });
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;
}
// Stored-XSS-Schutz (Pentest 2026-05-20 MEDIUM 30.13):
// Multer prüfte beim Upload nur den client-gemeldeten MIME-Type.
// Eine `.html`-Datei mit `Content-Type: application/pdf` rutschte
// durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert.
//
// Default: Content-Disposition: attachment → Browser lädt nur runter.
// Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button,
// Vertragsdokumente-Vorschau) per ?disposition=inline, ABER nur wenn
// die ersten Bytes der Datei das Magic eines bekannten safe Typs
// (PDF, PNG, JPEG, GIF, WebP) zeigen. Bei Mismatch fällt's auf
// attachment zurück Stored XSS bleibt weiterhin unmöglich.
//
// Pentest 101.1 (INFO, 2026-06-22): R101.1 berichtete, dass inline
// nie greift. Die Logik selbst ist OK; um künftige Regressionen
// sichtbar zu machen, loggen wir jetzt, wenn `inline` zwar angefragt
// wurde, aber wegen Magic-Byte-Mismatch oder Read-Fehler abgelehnt
// wird (passiert im Normalfall NIE bei echten PDFs/Images).
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
const wantsInline = req.query.disposition === 'inline';
const safeContentType = wantsInline ? detectSafeContentType(absolute) : null;
if (wantsInline && !safeContentType) {
console.warn(
`[fileDownload] inline angefragt, aber Magic-Byte-Check fehlgeschlagen: ${requested}`,
);
}
res.setHeader('X-Content-Type-Options', 'nosniff');
if (safeContentType) {
res.setHeader('Content-Type', safeContentType);
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
} else {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
}
res.sendFile(absolute);
}
/**
* Liest die ersten 12 Bytes der Datei und gibt einen MIME-Type zurück,
* wenn die Datei einer bekannten Whitelist (PDF, PNG, JPEG, GIF, WebP)
* entspricht. Sonst `null` dann wird die Datei als attachment serviert.
*
* Wird nur aufgerufen, wenn `?disposition=inline` angefragt wurde, damit
* der Standardfluss (attachment) ohne zusätzlichen Disk-Read auskommt.
*/
function detectSafeContentType(absolute: string): string | null {
let fd: number | null = null;
try {
fd = fs.openSync(absolute, 'r');
const head = Buffer.alloc(12);
const bytesRead = fs.readSync(fd, head, 0, 12, 0);
if (bytesRead < 5) return null; // Datei zu klein für jede Magic-Sig
if (head.subarray(0, 5).toString('latin1') === '%PDF-') return 'application/pdf';
if (bytesRead >= 8
&& head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
) return 'image/png';
if (bytesRead >= 3 && head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return 'image/jpeg';
if (bytesRead >= 6
&& (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|| head.subarray(0, 6).toString('latin1') === 'GIF89a')
) return 'image/gif';
if (bytesRead >= 12
&& head.subarray(0, 4).toString('latin1') === 'RIFF'
&& head.subarray(8, 12).toString('latin1') === 'WEBP'
) return 'image/webp';
return null;
} catch (err) {
console.warn(`[fileDownload] Magic-Byte-Read fehlgeschlagen für ${absolute}:`, err);
return null;
} finally {
if (fd !== null) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
}