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:
+13
-4
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user