PdfDragButton R131: 60s-Download-Token statt Access-Token, nur DownloadURL

Pentest R131 (LOW-MEDIUM): Der Drag-Button haengte den 15-Min-Access-
Token an die URL und schrieb ihn per text/plain + text/uri-list beim
Drag mit. Ein Fehl-Drop in ein Text-/Chat-/URL-Feld haette den vollen
Access-Token (alle Berechtigungen, 15 Min) als lesbaren Text geleakt.

Fix:
- fileUrl() akzeptiert jetzt optionalen expliziten Token.
- PdfDragButton nutzt den kurzlebigen 60s-Download-Token
  (authApi.getDownloadToken(), type:download, nur ?token=) statt des
  Access-Tokens. Modul-weiter Cache mit Dedup, auf mount + hover
  vorgewaermt (dragstart ist synchron, kann nicht awaiten).
- Es wird NUR noch DownloadURL im DataTransfer gesetzt, keine
  text/plain- oder text/uri-list-Repraesentation -> Fehl-Drop in ein
  Textfeld erzeugt gar keinen sichtbaren Text.
- Klick-Vorschau nutzt ebenfalls bevorzugt den Download-Token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:12:24 +02:00
co-authored by Claude Opus 4.8
parent 8e74f7733a
commit 7d062bf9b2
3 changed files with 95 additions and 14 deletions
+13 -4
View File
@@ -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,
+74 -8
View File
@@ -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<string | null> | null = null;
const TOKEN_TTL_MS = 45_000; // < 60s Server-Lebensdauer
async function warmDownloadToken(): Promise<string | null> {
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({
<span className={`inline-flex items-center gap-1 ${className}`}>
<span
draggable
onMouseEnter={warmDownloadToken}
onFocus={warmDownloadToken}
onDragStart={handleDragStart}
onClick={() => 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"
>
+8 -2
View File
@@ -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