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.
// Beim Download bestimmt res.sendFile() den Content-Type aus der
// Extension also `text/html` und der Browser hätte das als
// Stored-XSS gerendert. `X-Content-Type-Options: nosniff` schützt
// nicht, wenn der Server selbst text/html liefert.
// Stored-XSS gerendert.
//
// Fix: alle Files via Content-Disposition: attachment ausliefern.
// Der Browser lädt herunter statt zu rendern, egal welcher Type.
// Für legitime PDF/Bild-Vorschau ist das vertretbar Browser
// öffnen den Download dann eben aus dem Datei-Manager.
// 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');
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);
}