Files
opencrm/backend/src/controllers/fileDownload.controller.ts
T
duffyduckandClaude Opus 4.7 818f801939 Pentest R101.1: Inline-Preview-Pfad refaktoriert + Diagnose-Log
R101.1 INFO/funktional: Pentester sieht Content-Disposition:
attachment auch bei ?disposition=inline. Die Logik im Controller
ist korrekt und liefert beim Direkttest gegen echte PDFs
application/pdf, der Pfad lässt sich aber in Prod nicht
reproduzieren.

Refaktoriert:
- Magic-Byte-Check in detectSafeContentType() extrahiert
- File-Descriptor wird in finally garantiert geschlossen
- Short-Read-Fälle (bytesRead < n) explizit geguardet
- console.warn wenn inline angefragt aber Magic-Byte-Mismatch
  oder Read-Crash – damit der Fall in Prod-Logs sichtbar wird
  falls er wieder auftritt

Sicherheits-Verhalten unverändert: Mismatch → attachment
(Stored-XSS-Schutz aus R30.13).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 20:54:16 +02:00

159 lines
6.6 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 === '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 */ }
}
}
}