diff --git a/docs/todo.md b/docs/todo.md index e960f391..63d1ae5e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -110,10 +110,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung Ausweisnummer-Copy-Button), Vertragsansicht (`ContractDetail`, Bankkarte- + Ausweis-Card), Kundenakte-Tabs Bankkarten + Ausweise (`CustomerDetail`, neben Anzeigen/Download). Erscheint nur bei vorhandenem `documentPath`. - - Nutzt bestehende `fileUrl()`-Download-URL (Token als Query, Per-File- - Ownership-Check unverändert) – kein neuer Endpoint, keine neue Angriffs- - fläche. Ausweitung auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, - Datenschutz) folgt bei Bedarf über dieselbe Komponente. + - Nutzt bestehende `fileUrl()`-Download-URL (Per-File-Ownership-Check + 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. - [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 f7dfbd03..a115d0ac 100644 --- a/frontend/src/components/ui/PdfDragButton.tsx +++ b/frontend/src/components/ui/PdfDragButton.tsx @@ -1,5 +1,7 @@ +import { useEffect } from 'react'; import { GripVertical, FileDown, Info } from 'lucide-react'; import { fileUrl } from '../../utils/fileUrl'; +import { authApi } from '../../services/api'; /** * Ziehbares Element, mit dem eine im Backend hinterlegte Datei (i.d.R. ein @@ -19,6 +21,16 @@ import { fileUrl } from '../../utils/fileUrl'; * - `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): + * - Für die Drag-URL wird ein KURZLEBIGER 60s-Download-Token + * (`authApi.getDownloadToken()`, `type:"download"`, nur `?token=` + * nutzbar) verwendet – NICHT der 15-Minuten-Access-Token mit vollen + * Berechtigungen. Landet ein Drop versehentlich in einem Textfeld, + * kann bestenfalls ein 60s gültiger Downloads-only-Token auftauchen. + * - Es wird AUSSCHLIESSLICH `DownloadURL` gesetzt, keine `text/plain`- + * oder `text/uri-list`-Repräsentation. Ein Fehl-Drop in ein Text-/ + * Chat-/URL-Feld erzeugt daher gar keinen sichtbaren Text. */ interface PdfDragButtonProps { /** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */ @@ -46,6 +58,35 @@ 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, @@ -54,6 +95,11 @@ export default function PdfDragButton({ className = '', size = 'sm', }: PdfDragButtonProps) { + // Token beim Mounten vorwärmen, damit der erste Drag sofort klappt. + useEffect(() => { + if (path) warmDownloadToken(); + }, [path]); + if (!path) return null; const ext = extOf(path); @@ -63,18 +109,26 @@ export default function PdfDragButton({ const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_'); const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`; - // Absolute URL, damit der Drop in fremde Apps die Datei laden kann. - // fileUrl() hängt das Auth-Token als Query-Parameter an (Download). - const absolute = `${window.location.origin}${fileUrl(path)}`; - const handleDragStart = (e: React.DragEvent) => { + // Nur der kurzlebige Download-Token wird in die Drag-URL geschrieben. + // Ist noch keiner da (Netzwerk langsamer als der erste Griff), Drag + // abbrechen und Token vorwärmen – lieber kein Drag als ein Access-Token- + // Leak. Der nächste Versuch (nach Hover) hat den Token dann. + const token = cachedToken; + if (!token) { + warmDownloadToken(); + e.preventDefault(); + return; + } + const absolute = `${window.location.origin}${fileUrl(path, { token })}`; try { + // NUR DownloadURL – bewusst keine text/plain- oder text/uri-list- + // Repräsentation (sonst würde ein Fehl-Drop die volle URL als Text + // ausgeben). Siehe Pentest R131. e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${absolute}`); } catch { - /* manche Browser werfen bei DownloadURL – dann nur die URI-Fallbacks */ + /* manche Browser werfen bei DownloadURL – dann passiert einfach nichts */ } - e.dataTransfer.setData('text/uri-list', absolute); - e.dataTransfer.setData('text/plain', absolute); e.dataTransfer.effectAllowed = 'copy'; }; @@ -84,8 +138,20 @@ export default function PdfDragButton({ window.open(fileUrl(path, { inline: true }), '_blank', 'noopener')} + onClick={() => + // 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', + ) + } 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" > diff --git a/frontend/src/utils/fileUrl.ts b/frontend/src/utils/fileUrl.ts index c205f1d2..55126c9b 100644 --- a/frontend/src/utils/fileUrl.ts +++ b/frontend/src/utils/fileUrl.ts @@ -24,9 +24,15 @@ export function viewUrl(path: string | null | undefined): string { return fileUrl(path, { inline: true }); } -export function fileUrl(path: string | null | undefined, opts?: { inline?: boolean }): string { +export function fileUrl( + path: string | null | undefined, + opts?: { inline?: boolean; token?: string }, +): string { if (!path) return ''; - const token = getAccessToken(); + // Expliziter Token (z.B. kurzlebiger 60s-Download-Token) hat Vorrang vor + // dem langlebigen Access-Token. Genutzt vom PdfDragButton, damit bei einem + // Fehl-Drop kein 15-Min-Vollzugriffs-Token in fremde Kontexte leakt. + const token = opts?.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