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// * * 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 { 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 */ } } } }