From 0bd2f9be7eab60ab19b934c043954ef4a256c29b Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sat, 30 May 2026 09:19:04 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20"Anzeigen"-Buttons=20=C3=B6ffnen=20Datei?= =?UTF-8?q?=20wieder=20im=20Browser-Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `` 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) --- .../controllers/fileDownload.controller.ts | 50 ++++++++++++++++--- .../components/contracts/InvoicesSection.tsx | 4 +- .../src/pages/contracts/ContractDetail.tsx | 12 ++--- .../src/pages/customers/CustomerDetail.tsx | 14 +++--- frontend/src/pages/settings/GDPRDashboard.tsx | 4 +- frontend/src/pages/settings/PdfTemplates.tsx | 4 +- frontend/src/utils/fileUrl.ts | 18 ++++++- 7 files changed, 78 insertions(+), 28 deletions(-) diff --git a/backend/src/controllers/fileDownload.controller.ts b/backend/src/controllers/fileDownload.controller.ts index c5f31975..fed0e70c 100644 --- a/backend/src/controllers/fileDownload.controller.ts +++ b/backend/src/controllers/fileDownload.controller.ts @@ -84,15 +84,51 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise = { INTERIM: 'Zwischenrechnung', @@ -121,7 +121,7 @@ export default function InvoicesSection({ {invoice.documentPath && (
= { ELECTRICITY: 'Strom', @@ -2118,7 +2118,7 @@ export default function ContractDetail() { {c.cancellationLetterPath ? (
window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')} + onClick={() => window.open(viewUrl(`/uploads/${request.proofDocument}`), '_blank')} title="Löschnachweis anzeigen" > diff --git a/frontend/src/pages/settings/PdfTemplates.tsx b/frontend/src/pages/settings/PdfTemplates.tsx index b3341db4..5d785a32 100644 --- a/frontend/src/pages/settings/PdfTemplates.tsx +++ b/frontend/src/pages/settings/PdfTemplates.tsx @@ -9,7 +9,7 @@ import Input from '../../components/ui/Input'; import Badge from '../../components/ui/Badge'; import Modal from '../../components/ui/Modal'; import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react'; -import { fileUrl } from '../../utils/fileUrl'; +import { viewUrl } from '../../utils/fileUrl'; export default function PdfTemplates() { const navigate = useNavigate(); @@ -96,7 +96,7 @@ export default function PdfTemplates() { - + diff --git a/frontend/src/utils/fileUrl.ts b/frontend/src/utils/fileUrl.ts index 742735ac..c205f1d2 100644 --- a/frontend/src/utils/fileUrl.ts +++ b/frontend/src/utils/fileUrl.ts @@ -15,11 +15,25 @@ */ import { getAccessToken } from '../services/api'; -export function fileUrl(path: string | null | undefined): string { +/** + * Kurzform für Inline-Vorschau: identisch zu `fileUrl(path, { inline: true })`. + * Verwenden für „Anzeigen"-Links / target="_blank"-Vorschauen. Default- + * `fileUrl(path)` bleibt für Downloads (Content-Disposition: attachment). + */ +export function viewUrl(path: string | null | undefined): string { + return fileUrl(path, { inline: true }); +} + +export function fileUrl(path: string | null | undefined, opts?: { inline?: boolean }): string { if (!path) return ''; const token = getAccessToken(); const normalizedPath = path.startsWith('/') ? path : '/' + path; - const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}`; + // `?disposition=inline` schaltet die Anzeige im Browser-Tab ein, + // der Backend-Controller bleibt aber nur dann inline, wenn die + // Datei tatsächlich ein safe Type (PDF/PNG/JPEG/GIF/WebP) ist – + // sonst fällt's auf attachment zurück. Default attachment. + const dispParam = opts?.inline ? '&disposition=inline' : ''; + const base = `/api/files/download?path=${encodeURIComponent(normalizedPath)}${dispParam}`; if (!token) return base; return `${base}&token=${encodeURIComponent(token)}`; }