From 3073acb4b0f6777f2d90992c6161dc47b06e5a4c Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 30 Jul 2026 14:11:17 +0200 Subject: [PATCH] PdfDragButton: echten Datei-Inhalt (Blob) ziehen statt Link Thunderbird & Co. hatten beim URL-basierten Drag nur einen LINK als Anhang gespeichert (0 Bytes) und die Datei erst beim Senden nachgeladen -> Fehler, weil kurzlebiger Token abgelaufen. Der User erwartet, dass die Datei direkt angehaengt wird, nicht der Link. Loesung: Datei wird vorab per Bearer-Auth (Axios-Instanz) als Blob geladen und beim dragstart als echter Datei-Inhalt uebergeben: dataTransfer.items.add(File) + DownloadURL mit lokaler blob:-URL. -> Datei-INHALT wird uebertragen, kein Link. Blob wird beim Mount vorgeladen (Ladezustand 'laedt ...'); der Endpoint /files/download schreibt kein Audit-Log, daher kein Spam. Security R131 damit vollstaendig erledigt: im Drag steckt weder eine Server-URL noch ein Token -> ein Fehl-Drop kann gar nichts mehr leaken (kein Access-, kein Download-Token). Token-Cache/getDownloadToken im Drag-Pfad entfernt. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/todo.md | 22 +- frontend/src/components/ui/PdfDragButton.tsx | 205 +++++++++---------- 2 files changed, 105 insertions(+), 122 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 63d1ae5e..ee64b6f3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -114,15 +114,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung unverändert) – kein neuer Endpoint, keine neue Angriffsfläche. Ausweitung auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, Datenschutz) folgt bei Bedarf über dieselbe Komponente. - - **Nachtrag Pentest R131 (LOW-MEDIUM, gefixt):** Erste Version hängte den - 15-Min-Access-Token an und schrieb ihn per `text/plain`/`text/uri-list` - beim Drag mit → Fehl-Drop in ein Textfeld hätte den Voll-Token als - lesbaren Text geleakt. Fix: (a) kurzlebiger 60s-Download-Token - (`authApi.getDownloadToken()`, `type:"download"`, nur `?token=`) statt - Access-Token – modul-weiter Cache, auf mount+hover vorgewärmt; (b) NUR - noch `DownloadURL` im DataTransfer, keine text/plain-Repräsentation → - Fehl-Drop in Text-/Chat-Feld erzeugt gar nichts. Klick-Vorschau nutzt - ebenfalls bevorzugt den Download-Token. + - **Nachtrag Pentest R131 + Funktions-Iteration → Blob-Ansatz (final):** + Zwischenstände (Access-Token → 60s-Download-Token, Text-Formate raus/rein) + hatten entweder ein Token-Leak-Risiko ODER hängten nur einen LINK an + (Thunderbird lud die URL erst beim Senden → 0 Bytes / Fehler, Token + abgelaufen). Endstand: Datei wird VORAB per Bearer-Auth als **Blob** + geladen und beim Drag als echter Datei-Inhalt übergeben + (`dataTransfer.items.add(File)` + `DownloadURL` mit lokaler `blob:`-URL). + → Es wird der DATEI-INHALT übertragen, kein Link. Sicherheit R131 damit + vollständig erledigt: im Drag steckt WEDER Server-URL NOCH Token, ein + Fehl-Drop kann nichts mehr leaken. Blob wird beim Mount vorgeladen + (Ladezustand „lädt …", `/files/download` schreibt kein Audit-Log, daher + kein Audit-Spam). Zuverlässiges Datei-Drop v.a. in Chromium; Ziele ohne + File-Annahme → Klick-Fallback (Tab öffnen, manuell anhängen). - [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30) - In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label, diff --git a/frontend/src/components/ui/PdfDragButton.tsx b/frontend/src/components/ui/PdfDragButton.tsx index bf7135ec..bd312aad 100644 --- a/frontend/src/components/ui/PdfDragButton.tsx +++ b/frontend/src/components/ui/PdfDragButton.tsx @@ -1,41 +1,35 @@ -import { useEffect } from 'react'; -import { GripVertical, FileDown, Info } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { GripVertical, FileDown, Info, Loader2 } from 'lucide-react'; +import api from '../../services/api'; import { fileUrl } from '../../utils/fileUrl'; -import { authApi, getAccessToken } from '../../services/api'; /** * Ziehbares Element, mit dem eine im Backend hinterlegte Datei (i.d.R. ein - * PDF-Scan) direkt aus dem Browser heraus in eine fremde Anwendung – z.B. - * das Anhang-Feld eines Mail-Fensters oder den Datei-Explorer – gezogen - * werden kann. + * PDF-Scan) als ECHTE DATEI aus dem Browser heraus in eine fremde Anwendung + * – Mail-Anhang, Datei-Explorer – gezogen werden kann. * - * Technik: Beim `dragstart` setzen wir den Chromium-spezifischen - * `DownloadURL`-DataTransfer-Eintrag (`::`). - * Chrome/Edge laden die Datei beim Ablegen von dieser URL nach und legen - * sie am Zielort als echte Datei ab. + * Ansatz (bewusst Blob statt URL): + * Die Datei wird VORAB per authentifiziertem Request (Bearer-Header über die + * Axios-Instanz) als Blob geladen und beim `dragstart` als Datei-Inhalt in + * den DataTransfer gelegt: + * - `dataTransfer.items.add(File)` → echte Datei für Web-/App-Ziele, + * - `DownloadURL` mit einer `blob:`-Object-URL → Chromium liefert die Bytes + * beim Drop selbst aus (Explorer/Datei-Manager). + * Dadurch wird der DATEI-INHALT übertragen, NICHT ein Link. Das behebt den + * Fall, dass Mail-Programme (z.B. Thunderbird) nur eine URL als Anhang + * speichern und die Datei erst beim Senden nachladen (→ 0 Bytes / Fehler, + * weil kurzlebiger Token längst abgelaufen ist). * - * WICHTIG – Grenzen der Browser-Plattform: - * - „PDF per Strg+V als DATEI einfügen" ist im Browser NICHT möglich - * (die Web-Zwischenablage darf die OS-Datei-Zwischenablage / CF_HDROP - * nicht befüllen). Deshalb Drag-and-Drop statt Clipboard. - * - `DownloadURL` funktioniert nur in Chromium-Browsern (Chrome/Edge), - * nicht in Firefox. Dort passiert beim Ziehen nichts – der Klick- - * Fallback (Datei im neuen Tab öffnen) bleibt aber nutzbar. + * SICHERHEIT (Pentest R131): Im Drag steckt jetzt WEDER eine Server-URL NOCH + * ein Token – nur der Datei-Inhalt bzw. eine lokale `blob:`-URL. Ein Fehl-Drop + * kann also keinen Token (weder Access- noch Download-Token) mehr leaken. * - * SICHERHEIT (Pentest R131): - * - In die URL wird ein KURZLEBIGER 60s-Download-Token - * (`authApi.getDownloadToken()`, `type:"download"`, nur `?token=` - * nutzbar) geschrieben statt des 15-Min-Access-Tokens mit vollen - * Rechten. Er wird proaktiv (mount + hover) vorgewärmt. Das ist der - * wesentliche Fix (Token-SCOPE): ein Fehl-Drop kann höchstens eine 60s - * gültige Downloads-only-URL zeigen statt eines Voll-Access-Tokens. - * - Es werden im Normalfall bewusst alle drei Formate gesetzt - * (`DownloadURL` für Explorer/Outlook-Desktop, `text/uri-list` + - * `text/plain` für web-basierte Mail-/Editor-Ziele) – sonst reagiert - * das Ziel-Fenster beim Drop nicht. Nur im seltenen Rennen „Drag bevor - * der Token da ist" wird ausschließlich `DownloadURL` mit Access-Token- - * Fallback gesetzt (Nativ-Format, kein Klartext-Leak). Der Drag wird - * NIE abgebrochen (sonst käme im Ziel keine Datei an). + * Grenzen der Browser-Plattform: + * - „PDF per Strg+V als DATEI einfügen" bleibt im Browser unmöglich → Drag. + * - Zuverlässiges Datei-Drop nach draußen funktioniert am besten in + * Chromium (Chrome/Edge). Ziele, die weder `File` noch `DownloadURL` + * annehmen (manche Webmail-Compose-Felder), erhalten keine echte Datei – + * dort bleibt der Klick-Fallback (Datei im Tab öffnen → manuell anhängen). */ interface PdfDragButtonProps { /** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */ @@ -63,35 +57,6 @@ function extOf(path: string): string { return dot === -1 ? '' : clean.slice(dot + 1).toLowerCase(); } -// Modul-weiter Cache für den 60s-Download-Token. Alle PdfDragButtons einer -// Seite teilen sich einen Token (dedup über `inflight`), und er wird knapp -// unter der Server-Lebensdauer erneuert. `dragstart` ist synchron und kann -// nicht `await`en – deshalb halten wir den Token proaktiv (mount + hover) -// frisch und lesen ihn beim Drag direkt aus dem Cache. -let cachedToken: string | null = null; -let cachedAt = 0; -let inflight: Promise | null = null; -const TOKEN_TTL_MS = 45_000; // < 60s Server-Lebensdauer - -async function warmDownloadToken(): Promise { - const now = Date.now(); - if (cachedToken && now - cachedAt < TOKEN_TTL_MS) return cachedToken; - if (inflight) return inflight; - inflight = authApi - .getDownloadToken() - .then((t) => { - cachedToken = t; - cachedAt = Date.now(); - inflight = null; - return t; - }) - .catch(() => { - inflight = null; - return null; - }); - return inflight; -} - export default function PdfDragButton({ path, filename, @@ -100,9 +65,37 @@ export default function PdfDragButton({ className = '', size = 'sm', }: PdfDragButtonProps) { - // Token beim Mounten vorwärmen, damit der erste Drag sofort klappt. + const blobRef = useRef(null); + const objectUrlRef = useRef(null); + const fetchingRef = useRef(false); + const [state, setState] = useState<'idle' | 'loading' | 'ready'>('idle'); + + // Datei-Bytes einmal laden (auth über Bearer-Header der Axios-Instanz). + const ensureBlob = () => { + if (!path || blobRef.current || fetchingRef.current) return; + fetchingRef.current = true; + setState('loading'); + api + .get('/files/download', { params: { path }, responseType: 'blob' }) + .then((res) => { + blobRef.current = res.data as Blob; + setState('ready'); + }) + .catch(() => { + setState('idle'); + }) + .finally(() => { + fetchingRef.current = false; + }); + }; + + // Beim Mount vorladen, damit der erste Drag sofort eine echte Datei liefert. useEffect(() => { - if (path) warmDownloadToken(); + if (path) ensureBlob(); + return () => { + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [path]); if (!path) return null; @@ -110,79 +103,65 @@ export default function PdfDragButton({ const ext = extOf(path); const mime = MIME_BY_EXT[ext] || 'application/octet-stream'; const base = (filename && filename.trim()) || 'dokument'; - // Ungültige Dateinamen-Zeichen entschärfen, Endung sicherstellen. const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_'); const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`; const handleDragStart = (e: React.DragEvent) => { - // Der Drag wird NIE abgebrochen (sonst käme im Ziel gar keine Datei an). - const dl = cachedToken; - const absolute = (t: string) => `${window.location.origin}${fileUrl(path, { token: t })}`; + const blob = blobRef.current; + if (!blob) { + // Bytes noch nicht da → keinen halben (Link-)Anhang erzeugen. Laden + // anstoßen; der nächste Versuch liefert dann die echte Datei. + ensureBlob(); + e.preventDefault(); + return; + } + // Frische Object-URL, vorherige freigeben. + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current); + const url = URL.createObjectURL(blob); + objectUrlRef.current = url; + + const file = new File([blob], name, { type: mime }); try { - if (dl) { - // Normalfall: kurzlebiger 60s-Download-Token (auf mount+hover - // vorgewärmt). Alle drei Formate setzen, damit sowohl Desktop-Ziele - // (Explorer/Outlook via DownloadURL) als auch web-basierte Mail-/ - // Editor-Fenster (die nur text/uri-list bzw. text/plain verstehen) - // auf den Drop reagieren. - // - // Pentest R131: Der wesentliche Fix ist der Token-SCOPE – hier steckt - // NUR ein 60s gültiger Downloads-only-Token in der URL, nicht mehr - // der 15-Min-Access-Token mit vollen Rechten. Ein versehentlicher - // Drop in ein Textfeld kann also höchstens eine 60s-Downloads-URL - // zeigen (Empfehlung #1). Das Weglassen der Text-Formate (#2) war - // „optional" und hat das Ziehen ins Mail-Fenster gebrochen → bewusst - // nicht umgesetzt. - const url = absolute(dl); - e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${url}`); - e.dataTransfer.setData('text/uri-list', url); - e.dataTransfer.setData('text/plain', url); - // Mozilla-Format (Thunderbird/Firefox erwarten `URL\nTitel`). - e.dataTransfer.setData('text/x-moz-url', `${url}\n${name}`); - } else { - // Seltenes Rennen: Download-Token noch nicht vorgewärmt. Dann NUR - // DownloadURL mit Access-Token-Fallback setzen (Nativ-Format, wird in - // Web-Textfeldern nicht als Klartext ausgegeben) – kein Token-Leak - // als Text. Für den nächsten Drag vorwärmen. - e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${absolute(getAccessToken() || '')}`); - warmDownloadToken(); - } + e.dataTransfer.items.add(file); } catch { - /* manche Browser werfen bei DownloadURL – dann greifen die Text-Formate */ + /* ältere Browser: dann trägt DownloadURL den Drop */ + } + try { + e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${url}`); + } catch { + /* noop */ } e.dataTransfer.effectAllowed = 'copy'; }; const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4'; + const loading = state === 'loading'; return ( - // Beim Klick (Tab-Vorschau) ebenfalls bevorzugt den kurzlebigen - // Download-Token nutzen – auf Hover ist er bereits vorgewärmt. - // Fällt nur auf den Access-Token zurück, wenn noch keiner da ist - // (identisch zum bestehenden Verhalten aller „Anzeigen"-Links). - window.open( - fileUrl(path, { inline: true, token: cachedToken || undefined }), - '_blank', - 'noopener', - ) + onClick={() => window.open(fileUrl(path, { inline: true }), '_blank', 'noopener')} + title={ + title || + (loading + ? 'Datei wird geladen …' + : 'In Mail-Anhang/Explorer ziehen (am besten Chrome/Edge). Klick: im Tab öffnen.') } - title={title || 'In Outlook/Explorer ziehen (Chrome/Edge). Klick: im Tab öffnen.'} - className="inline-flex items-center gap-1 cursor-grab active:cursor-grabbing select-none rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs text-gray-600 hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50 transition-colors" + className={`inline-flex items-center gap-1 select-none rounded border border-gray-200 bg-gray-50 px-1.5 py-0.5 text-xs text-gray-600 transition-colors hover:text-blue-600 hover:border-blue-300 hover:bg-blue-50 ${ + state === 'ready' ? 'cursor-grab active:cursor-grabbing' : 'cursor-default' + }`} > - - {label} + {loading ? : } + {loading ? 'lädt …' : label}