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) <noreply@anthropic.com>
This commit is contained in:
+13
-9
@@ -114,15 +114,19 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
|||||||
unverändert) – kein neuer Endpoint, keine neue Angriffsfläche. Ausweitung
|
unverändert) – kein neuer Endpoint, keine neue Angriffsfläche. Ausweitung
|
||||||
auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, Datenschutz) folgt bei
|
auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, Datenschutz) folgt bei
|
||||||
Bedarf über dieselbe Komponente.
|
Bedarf über dieselbe Komponente.
|
||||||
- **Nachtrag Pentest R131 (LOW-MEDIUM, gefixt):** Erste Version hängte den
|
- **Nachtrag Pentest R131 + Funktions-Iteration → Blob-Ansatz (final):**
|
||||||
15-Min-Access-Token an und schrieb ihn per `text/plain`/`text/uri-list`
|
Zwischenstände (Access-Token → 60s-Download-Token, Text-Formate raus/rein)
|
||||||
beim Drag mit → Fehl-Drop in ein Textfeld hätte den Voll-Token als
|
hatten entweder ein Token-Leak-Risiko ODER hängten nur einen LINK an
|
||||||
lesbaren Text geleakt. Fix: (a) kurzlebiger 60s-Download-Token
|
(Thunderbird lud die URL erst beim Senden → 0 Bytes / Fehler, Token
|
||||||
(`authApi.getDownloadToken()`, `type:"download"`, nur `?token=`) statt
|
abgelaufen). Endstand: Datei wird VORAB per Bearer-Auth als **Blob**
|
||||||
Access-Token – modul-weiter Cache, auf mount+hover vorgewärmt; (b) NUR
|
geladen und beim Drag als echter Datei-Inhalt übergeben
|
||||||
noch `DownloadURL` im DataTransfer, keine text/plain-Repräsentation →
|
(`dataTransfer.items.add(File)` + `DownloadURL` mit lokaler `blob:`-URL).
|
||||||
Fehl-Drop in Text-/Chat-Feld erzeugt gar nichts. Klick-Vorschau nutzt
|
→ Es wird der DATEI-INHALT übertragen, kein Link. Sicherheit R131 damit
|
||||||
ebenfalls bevorzugt den Download-Token.
|
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)
|
- [x] **📋 Vertragsformular: Copy-Buttons für Bankkarte (IBAN) + Ausweis** (2026-07-30)
|
||||||
- In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label,
|
- In „Kundendaten verknüpfen" je ein Kopieren-Button neben dem Label,
|
||||||
|
|||||||
@@ -1,41 +1,35 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { GripVertical, FileDown, Info } from 'lucide-react';
|
import { GripVertical, FileDown, Info, Loader2 } from 'lucide-react';
|
||||||
|
import api from '../../services/api';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
import { fileUrl } from '../../utils/fileUrl';
|
||||||
import { authApi, getAccessToken } from '../../services/api';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ziehbares Element, mit dem eine im Backend hinterlegte Datei (i.d.R. ein
|
* 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.
|
* PDF-Scan) als ECHTE DATEI aus dem Browser heraus in eine fremde Anwendung
|
||||||
* das Anhang-Feld eines Mail-Fensters oder den Datei-Explorer – gezogen
|
* – Mail-Anhang, Datei-Explorer – gezogen werden kann.
|
||||||
* werden kann.
|
|
||||||
*
|
*
|
||||||
* Technik: Beim `dragstart` setzen wir den Chromium-spezifischen
|
* Ansatz (bewusst Blob statt URL):
|
||||||
* `DownloadURL`-DataTransfer-Eintrag (`<mime>:<dateiname>:<absolute-url>`).
|
* Die Datei wird VORAB per authentifiziertem Request (Bearer-Header über die
|
||||||
* Chrome/Edge laden die Datei beim Ablegen von dieser URL nach und legen
|
* Axios-Instanz) als Blob geladen und beim `dragstart` als Datei-Inhalt in
|
||||||
* sie am Zielort als echte Datei ab.
|
* 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:
|
* SICHERHEIT (Pentest R131): Im Drag steckt jetzt WEDER eine Server-URL NOCH
|
||||||
* - „PDF per Strg+V als DATEI einfügen" ist im Browser NICHT möglich
|
* ein Token – nur der Datei-Inhalt bzw. eine lokale `blob:`-URL. Ein Fehl-Drop
|
||||||
* (die Web-Zwischenablage darf die OS-Datei-Zwischenablage / CF_HDROP
|
* kann also keinen Token (weder Access- noch Download-Token) mehr leaken.
|
||||||
* 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):
|
* Grenzen der Browser-Plattform:
|
||||||
* - In die URL wird ein KURZLEBIGER 60s-Download-Token
|
* - „PDF per Strg+V als DATEI einfügen" bleibt im Browser unmöglich → Drag.
|
||||||
* (`authApi.getDownloadToken()`, `type:"download"`, nur `?token=`
|
* - Zuverlässiges Datei-Drop nach draußen funktioniert am besten in
|
||||||
* nutzbar) geschrieben statt des 15-Min-Access-Tokens mit vollen
|
* Chromium (Chrome/Edge). Ziele, die weder `File` noch `DownloadURL`
|
||||||
* Rechten. Er wird proaktiv (mount + hover) vorgewärmt. Das ist der
|
* annehmen (manche Webmail-Compose-Felder), erhalten keine echte Datei –
|
||||||
* wesentliche Fix (Token-SCOPE): ein Fehl-Drop kann höchstens eine 60s
|
* dort bleibt der Klick-Fallback (Datei im Tab öffnen → manuell anhängen).
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
interface PdfDragButtonProps {
|
interface PdfDragButtonProps {
|
||||||
/** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */
|
/** 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();
|
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({
|
export default function PdfDragButton({
|
||||||
path,
|
path,
|
||||||
filename,
|
filename,
|
||||||
@@ -100,9 +65,37 @@ export default function PdfDragButton({
|
|||||||
className = '',
|
className = '',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
}: PdfDragButtonProps) {
|
}: PdfDragButtonProps) {
|
||||||
// Token beim Mounten vorwärmen, damit der erste Drag sofort klappt.
|
const blobRef = useRef<Blob | null>(null);
|
||||||
|
const objectUrlRef = useRef<string | null>(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(() => {
|
useEffect(() => {
|
||||||
if (path) warmDownloadToken();
|
if (path) ensureBlob();
|
||||||
|
return () => {
|
||||||
|
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
@@ -110,79 +103,65 @@ export default function PdfDragButton({
|
|||||||
const ext = extOf(path);
|
const ext = extOf(path);
|
||||||
const mime = MIME_BY_EXT[ext] || 'application/octet-stream';
|
const mime = MIME_BY_EXT[ext] || 'application/octet-stream';
|
||||||
const base = (filename && filename.trim()) || 'dokument';
|
const base = (filename && filename.trim()) || 'dokument';
|
||||||
// Ungültige Dateinamen-Zeichen entschärfen, Endung sicherstellen.
|
|
||||||
const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_');
|
const safeBase = base.replace(/[\\/:*?"<>|]+/g, '_');
|
||||||
const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`;
|
const name = safeBase.includes('.') ? safeBase : `${safeBase}${ext ? `.${ext}` : ''}`;
|
||||||
|
|
||||||
const handleDragStart = (e: React.DragEvent) => {
|
const handleDragStart = (e: React.DragEvent) => {
|
||||||
// Der Drag wird NIE abgebrochen (sonst käme im Ziel gar keine Datei an).
|
const blob = blobRef.current;
|
||||||
const dl = cachedToken;
|
if (!blob) {
|
||||||
const absolute = (t: string) => `${window.location.origin}${fileUrl(path, { token: t })}`;
|
// 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 {
|
try {
|
||||||
if (dl) {
|
e.dataTransfer.items.add(file);
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
} catch {
|
} 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';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4';
|
const iconSize = size === 'sm' ? 'w-3.5 h-3.5' : 'w-4 h-4';
|
||||||
|
const loading = state === 'loading';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={`inline-flex items-center gap-1 ${className}`}>
|
<span className={`inline-flex items-center gap-1 ${className}`}>
|
||||||
<span
|
<span
|
||||||
draggable
|
draggable={state === 'ready'}
|
||||||
onMouseEnter={warmDownloadToken}
|
onMouseEnter={ensureBlob}
|
||||||
onFocus={warmDownloadToken}
|
onFocus={ensureBlob}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onClick={() =>
|
onClick={() => window.open(fileUrl(path, { inline: true }), '_blank', 'noopener')}
|
||||||
// Beim Klick (Tab-Vorschau) ebenfalls bevorzugt den kurzlebigen
|
title={
|
||||||
// Download-Token nutzen – auf Hover ist er bereits vorgewärmt.
|
title ||
|
||||||
// Fällt nur auf den Access-Token zurück, wenn noch keiner da ist
|
(loading
|
||||||
// (identisch zum bestehenden Verhalten aller „Anzeigen"-Links).
|
? 'Datei wird geladen …'
|
||||||
window.open(
|
: 'In Mail-Anhang/Explorer ziehen (am besten Chrome/Edge). Klick: im Tab öffnen.')
|
||||||
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 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 ${
|
||||||
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"
|
state === 'ready' ? 'cursor-grab active:cursor-grabbing' : 'cursor-default'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<GripVertical className={`${iconSize} text-gray-400`} />
|
<GripVertical className={`${iconSize} text-gray-400`} />
|
||||||
<FileDown className={iconSize} />
|
{loading ? <Loader2 className={`${iconSize} animate-spin`} /> : <FileDown className={iconSize} />}
|
||||||
<span>{label}</span>
|
<span>{loading ? 'lädt …' : label}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="inline-flex cursor-help text-gray-400"
|
className="inline-flex cursor-help text-gray-400"
|
||||||
title="Ziehen funktioniert nur in Chrome/Edge. In Firefox stattdessen anklicken, um das PDF zu öffnen."
|
title="Ziehen als echte Datei funktioniert am besten in Chrome/Edge (Mail-Anhang, Explorer). In Firefox bzw. wenn das Ziel keine Datei annimmt, stattdessen anklicken und manuell anhängen."
|
||||||
>
|
>
|
||||||
<Info className={iconSize} />
|
<Info className={iconSize} />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user