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>
40 lines
1.8 KiB
TypeScript
40 lines
1.8 KiB
TypeScript
/**
|
||
* Baut eine Download-URL für ein im Backend gespeichertes Upload-File.
|
||
*
|
||
* Geht über `GET /api/files/download?path=...` – der Backend-Controller
|
||
* macht einen Per-File-Ownership-Check (Pfad → Resource → canAccessCustomer
|
||
* / canAccessContract). Damit kann auch ein eingeloggter User keine
|
||
* fremden Dateien abrufen, selbst wenn er den Pfad kennen würde.
|
||
*
|
||
* <a href> und window.open senden keinen Authorization-Header, daher
|
||
* Token als Query-Parameter (auth-Middleware akzeptiert `?token=<jwt>`).
|
||
*
|
||
* Trade-off: Tokens in URLs können in Logs/Referrer landen. Eine
|
||
* sauberere Lösung mit kurzlebigen Download-Tokens (signierte URLs)
|
||
* wäre v1.1-Item.
|
||
*/
|
||
import { getAccessToken } from '../services/api';
|
||
|
||
/**
|
||
* 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;
|
||
// `?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)}`;
|
||
}
|