0bd2f9be7e
Folge-Symptom des Pen-30.13-Fixes: alle file-downloads liefen mit
Content-Disposition: attachment – das ist gegen Stored-XSS richtig,
hat aber die "Anzeigen"-Buttons (Bankkarten / Ausweise /
Verträge / etc.) kaputtgemacht, weil der Browser jetzt
herunterlud statt im Tab zu öffnen.
Magic-Byte-basierter Whitelist-Pfad eingebaut: optional ?disposition=
inline am Download-Endpoint, ABER nur wenn die ersten Bytes der
Datei das Magic eines safe Typs zeigen (PDF, PNG, JPEG, GIF, WebP).
Bei Mismatch fällt's auf attachment zurück – Stored-XSS bleibt
weiterhin unmöglich, falls jemand HTML als .pdf hochlädt.
Frontend: neuer viewUrl(path)-Alias = fileUrl(path, {inline: true}).
Alle Stellen mit `<a href={fileUrl(...)} target="_blank">` oder
`window.open(fileUrl(...), '_blank')` (13 Stellen über CustomerDetail,
ContractDetail, PdfTemplates, GDPRDashboard, InvoicesSection)
nutzen jetzt viewUrl. Download-Stellen bleiben fileUrl
(= attachment, byte-genaues File-Save).
Live-verifiziert auf dev:
- ohne Param: attachment (default, Stored-XSS-Schutz)
- ?disposition=inline + echte PDF: inline + application/pdf
- ?disposition=inline + HTML als .pdf: attachment (Magic-Mismatch
→ Browser lädt herunter statt zu rendern)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
5.6 KiB
TypeScript
135 lines
5.6 KiB
TypeScript
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) 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.
|
||
const filename = path.basename(absolute).replace(/[^A-Za-z0-9._-]/g, '_');
|
||
const wantsInline = req.query.disposition === 'inline';
|
||
let useInline = false;
|
||
let inlineContentType: string | null = null;
|
||
if (wantsInline) {
|
||
try {
|
||
const fd = fs.openSync(absolute, 'r');
|
||
const head = Buffer.alloc(12);
|
||
fs.readSync(fd, head, 0, 12, 0);
|
||
fs.closeSync(fd);
|
||
if (head.subarray(0, 5).toString('latin1') === '%PDF-') {
|
||
useInline = true;
|
||
inlineContentType = 'application/pdf';
|
||
} else if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||
useInline = true;
|
||
inlineContentType = 'image/png';
|
||
} else if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
|
||
useInline = true;
|
||
inlineContentType = 'image/jpeg';
|
||
} else if (head.subarray(0, 6).toString('latin1') === 'GIF87a'
|
||
|| head.subarray(0, 6).toString('latin1') === 'GIF89a') {
|
||
useInline = true;
|
||
inlineContentType = 'image/gif';
|
||
} else if (head.subarray(0, 4).toString('latin1') === 'RIFF'
|
||
&& head.subarray(8, 12).toString('latin1') === 'WEBP') {
|
||
useInline = true;
|
||
inlineContentType = 'image/webp';
|
||
}
|
||
} catch { /* ignore – fällt auf attachment zurück */ }
|
||
}
|
||
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
if (useInline && inlineContentType) {
|
||
res.setHeader('Content-Type', inlineContentType);
|
||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||
} else {
|
||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||
}
|
||
res.sendFile(absolute);
|
||
}
|