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-
|
Ausweisnummer-Copy-Button), Vertragsansicht (`ContractDetail`, Bankkarte-
|
||||||
+ Ausweis-Card), Kundenakte-Tabs Bankkarten + Ausweise (`CustomerDetail`,
|
+ Ausweis-Card), Kundenakte-Tabs Bankkarten + Ausweise (`CustomerDetail`,
|
||||||
neben Anzeigen/Download). Erscheint nur bei vorhandenem `documentPath`.
|
neben Anzeigen/Download). Erscheint nur bei vorhandenem `documentPath`.
|
||||||
- Nutzt bestehende `fileUrl()`-Download-URL (Token als Query, Per-File-
|
- Nutzt bestehende `fileUrl()`-Download-URL (Per-File-Ownership-Check
|
||||||
Ownership-Check unverändert) – kein neuer Endpoint, keine neue Angriffs-
|
unverändert) – kein neuer Endpoint, keine neue Angriffsfläche. Ausweitung
|
||||||
fläche. Ausweitung auf weitere PDF-Stellen (Rechnungen, Vertragsdoks,
|
auf weitere PDF-Stellen (Rechnungen, Vertragsdoks, Datenschutz) folgt bei
|
||||||
Datenschutz) folgt bei Bedarf über dieselbe Komponente.
|
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)
|
- [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,5 +1,7 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { GripVertical, FileDown, Info } from 'lucide-react';
|
import { GripVertical, FileDown, Info } from 'lucide-react';
|
||||||
import { fileUrl } from '../../utils/fileUrl';
|
import { fileUrl } from '../../utils/fileUrl';
|
||||||
|
import { authApi } 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
|
||||||
@@ -19,6 +21,16 @@ import { fileUrl } from '../../utils/fileUrl';
|
|||||||
* - `DownloadURL` funktioniert nur in Chromium-Browsern (Chrome/Edge),
|
* - `DownloadURL` funktioniert nur in Chromium-Browsern (Chrome/Edge),
|
||||||
* nicht in Firefox. Dort passiert beim Ziehen nichts – der Klick-
|
* nicht in Firefox. Dort passiert beim Ziehen nichts – der Klick-
|
||||||
* Fallback (Datei im neuen Tab öffnen) bleibt aber nutzbar.
|
* 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 {
|
interface PdfDragButtonProps {
|
||||||
/** Server-Pfad der Datei (`documentPath`). Ohne Pfad wird nichts gerendert. */
|
/** 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();
|
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,
|
||||||
@@ -54,6 +95,11 @@ export default function PdfDragButton({
|
|||||||
className = '',
|
className = '',
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
}: PdfDragButtonProps) {
|
}: PdfDragButtonProps) {
|
||||||
|
// Token beim Mounten vorwärmen, damit der erste Drag sofort klappt.
|
||||||
|
useEffect(() => {
|
||||||
|
if (path) warmDownloadToken();
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
if (!path) return null;
|
if (!path) return null;
|
||||||
|
|
||||||
const ext = extOf(path);
|
const ext = extOf(path);
|
||||||
@@ -63,18 +109,26 @@ export default function PdfDragButton({
|
|||||||
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}` : ''}`;
|
||||||
|
|
||||||
// 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) => {
|
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 {
|
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}`);
|
e.dataTransfer.setData('DownloadURL', `${mime}:${name}:${absolute}`);
|
||||||
} catch {
|
} 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';
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,8 +138,20 @@ export default function PdfDragButton({
|
|||||||
<span className={`inline-flex items-center gap-1 ${className}`}>
|
<span className={`inline-flex items-center gap-1 ${className}`}>
|
||||||
<span
|
<span
|
||||||
draggable
|
draggable
|
||||||
|
onMouseEnter={warmDownloadToken}
|
||||||
|
onFocus={warmDownloadToken}
|
||||||
onDragStart={handleDragStart}
|
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.'}
|
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 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 });
|
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 '';
|
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;
|
const normalizedPath = path.startsWith('/') ? path : '/' + path;
|
||||||
// `?disposition=inline` schaltet die Anzeige im Browser-Tab ein,
|
// `?disposition=inline` schaltet die Anzeige im Browser-Tab ein,
|
||||||
// der Backend-Controller bleibt aber nur dann inline, wenn die
|
// der Backend-Controller bleibt aber nur dann inline, wenn die
|
||||||
|
|||||||
Reference in New Issue
Block a user