fix: "Anzeigen"-Buttons öffnen Datei wieder im Browser-Tab

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>
This commit is contained in:
2026-05-30 09:19:04 +02:00
parent 6a670df1c4
commit 0bd2f9be7e
7 changed files with 78 additions and 28 deletions
@@ -84,15 +84,51 @@ export async function downloadFile(req: AuthRequest, res: Response): Promise<voi
// durch und wurde mit Original-Extension auf Disk geschrieben. // durch und wurde mit Original-Extension auf Disk geschrieben.
// Beim Download bestimmt res.sendFile() den Content-Type aus der // Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als // Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt // Stored-XSS gerendert.
// nicht, wenn der Server selbst text/html liefert.
// //
// Fix: alle Files via Content-Disposition: attachment ausliefern. // Default: Content-Disposition: attachment → Browser lädt nur runter.
// Der Browser lädt herunter statt zu rendern, egal welcher Type. // Opt-in inline-Vorschau (Bank-Karten/Ausweis-Anzeigen-Button) per
// Für legitime PDF/Bild-Vorschau ist das vertretbar Browser // ?disposition=inline, ABER nur wenn die ersten Bytes der Datei das
// öffnen den Download dann eben aus dem Datei-Manager. // 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 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'); res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); 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); res.sendFile(absolute);
} }
@@ -9,7 +9,7 @@ import Select from '../ui/Select';
import Badge from '../ui/Badge'; import Badge from '../ui/Badge';
import { invoiceApi } from '../../services/api'; import { invoiceApi } from '../../services/api';
import type { Invoice, InvoiceType } from '../../types'; import type { Invoice, InvoiceType } from '../../types';
import { fileUrl } from '../../utils/fileUrl'; import { fileUrl, viewUrl } from '../../utils/fileUrl';
const invoiceTypeLabels: Record<InvoiceType, string> = { const invoiceTypeLabels: Record<InvoiceType, string> = {
INTERIM: 'Zwischenrechnung', INTERIM: 'Zwischenrechnung',
@@ -121,7 +121,7 @@ export default function InvoicesSection({
{invoice.documentPath && ( {invoice.documentPath && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<a <a
href={fileUrl(invoice.documentPath)} href={viewUrl(invoice.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm" className="flex items-center gap-1 text-blue-600 hover:text-blue-800 text-sm"
@@ -20,7 +20,7 @@ import CopyButton, { CopyableBlock } from '../../components/ui/CopyButton';
import { formatDate } from '../../utils/dateFormat'; import { formatDate } from '../../utils/dateFormat';
import { useProviderSettings } from '../../hooks/useProviderSettings'; import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types'; import type { ContractType, ContractStatus, SimCard, MeterReading, ContractTask, ContractTaskSubtask, ContractMeter, ContractDocument } from '../../types';
import { fileUrl } from '../../utils/fileUrl'; import { fileUrl, viewUrl } from '../../utils/fileUrl';
const typeLabels: Record<ContractType, string> = { const typeLabels: Record<ContractType, string> = {
ELECTRICITY: 'Strom', ELECTRICITY: 'Strom',
@@ -2118,7 +2118,7 @@ export default function ContractDetail() {
{c.cancellationLetterPath ? ( {c.cancellationLetterPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={fileUrl(c.cancellationLetterPath)} href={viewUrl(c.cancellationLetterPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2175,7 +2175,7 @@ export default function ContractDetail() {
<> <>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={fileUrl(c.cancellationConfirmationPath)} href={viewUrl(c.cancellationConfirmationPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2261,7 +2261,7 @@ export default function ContractDetail() {
{c.cancellationLetterOptionsPath ? ( {c.cancellationLetterOptionsPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={fileUrl(c.cancellationLetterOptionsPath)} href={viewUrl(c.cancellationLetterOptionsPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -2318,7 +2318,7 @@ export default function ContractDetail() {
<> <>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={fileUrl(c.cancellationConfirmationOptionsPath)} href={viewUrl(c.cancellationConfirmationOptionsPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -3484,7 +3484,7 @@ function ContractDocumentsSection({
{doc.documentType} {doc.documentType}
</span> </span>
<a <a
href={fileUrl(doc.documentPath)} href={viewUrl(doc.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline" className="text-sm text-blue-600 hover:underline"
@@ -21,7 +21,7 @@ import { formatDate } from '../../utils/dateFormat';
import { getContractTypeInfo } from '../../utils/contractInfo'; import { getContractTypeInfo } from '../../utils/contractInfo';
import { useProviderSettings } from '../../hooks/useProviderSettings'; import { useProviderSettings } from '../../hooks/useProviderSettings';
import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types'; import type { Address, BankCard, IdentityDocument, Meter, Customer, CustomerRepresentative, CustomerSummary, CustomerConsent, ConsentType, ConsentStatus, RepresentativeAuthorization } from '../../types';
import { fileUrl } from '../../utils/fileUrl'; import { fileUrl, viewUrl } from '../../utils/fileUrl';
export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) { export default function CustomerDetail({ portalCustomerId }: { portalCustomerId?: number } = {}) {
const { id } = useParams(); const { id } = useParams();
@@ -577,7 +577,7 @@ function BusinessDataCard({
{customer.businessRegistrationPath ? ( {customer.businessRegistrationPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={fileUrl(customer.businessRegistrationPath)} href={viewUrl(customer.businessRegistrationPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -628,7 +628,7 @@ function BusinessDataCard({
{customer.commercialRegisterPath ? ( {customer.commercialRegisterPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={fileUrl(customer.commercialRegisterPath)} href={viewUrl(customer.commercialRegisterPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -948,7 +948,7 @@ function BankCardsTab({
{card.documentPath ? ( {card.documentPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={fileUrl(card.documentPath)} href={viewUrl(card.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -1184,7 +1184,7 @@ function DocumentsTab({
{doc.documentPath ? ( {doc.documentPath ? (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<a <a
href={fileUrl(doc.documentPath)} href={viewUrl(doc.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -4123,7 +4123,7 @@ function ConsentTab({
{customer.privacyPolicyPath ? ( {customer.privacyPolicyPath ? (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<a <a
href={fileUrl(customer.privacyPolicyPath)} href={viewUrl(customer.privacyPolicyPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm flex items-center gap-1" className="text-blue-600 hover:underline text-sm flex items-center gap-1"
@@ -4441,7 +4441,7 @@ function AuthorizationsSection({ customerId, customerEmail }: { customerId: numb
{auth.documentPath ? ( {auth.documentPath ? (
<> <>
<a <a
href={fileUrl(auth.documentPath)} href={viewUrl(auth.documentPath)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-blue-600 hover:underline text-xs flex items-center gap-1" className="text-blue-600 hover:underline text-xs flex items-center gap-1"
@@ -7,7 +7,7 @@ import Card from '../../components/ui/Card';
import Button from '../../components/ui/Button'; import Button from '../../components/ui/Button';
import Select from '../../components/ui/Select'; import Select from '../../components/ui/Select';
import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react'; import { ArrowLeft, FileText, Users, CheckCircle, Clock, XCircle, AlertTriangle, Download, X, ChevronRight } from 'lucide-react';
import { fileUrl } from '../../utils/fileUrl'; import { viewUrl } from '../../utils/fileUrl';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
const STATUS_OPTIONS = [ const STATUS_OPTIONS = [
@@ -364,7 +364,7 @@ export default function GDPRDashboard() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(fileUrl(`/uploads/${request.proofDocument}`), '_blank')} onClick={() => window.open(viewUrl(`/uploads/${request.proofDocument}`), '_blank')}
title="Löschnachweis anzeigen" title="Löschnachweis anzeigen"
> >
<FileText className="w-4 h-4 text-blue-500" /> <FileText className="w-4 h-4 text-blue-500" />
+2 -2
View File
@@ -9,7 +9,7 @@ import Input from '../../components/ui/Input';
import Badge from '../../components/ui/Badge'; import Badge from '../../components/ui/Badge';
import Modal from '../../components/ui/Modal'; import Modal from '../../components/ui/Modal';
import { ArrowLeft, Plus, Edit, Trash2, FileText, Upload, Link2, Eye, Play } from 'lucide-react'; 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() { export default function PdfTemplates() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -96,7 +96,7 @@ export default function PdfTemplates() {
<Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten"> <Button variant="ghost" size="sm" onClick={() => setTestTemplate(t)} title="Testvorschau mit Vertragsdaten">
<Play className="w-4 h-4 text-green-500" /> <Play className="w-4 h-4 text-green-500" />
</Button> </Button>
<a href={fileUrl(t.templatePath)} target="_blank" rel="noopener noreferrer"> <a href={viewUrl(t.templatePath)} target="_blank" rel="noopener noreferrer">
<Button variant="ghost" size="sm" title="Leere Vorlage anzeigen"> <Button variant="ghost" size="sm" title="Leere Vorlage anzeigen">
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
+16 -2
View File
@@ -15,11 +15,25 @@
*/ */
import { getAccessToken } from '../services/api'; 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 ''; if (!path) return '';
const token = getAccessToken(); const token = getAccessToken();
const normalizedPath = path.startsWith('/') ? path : '/' + path; 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; if (!token) return base;
return `${base}&token=${encodeURIComponent(token)}`; return `${base}&token=${encodeURIComponent(token)}`;
} }