fix(PROJ-81): BUG-81-3 - PDF-Vorschau via pdf.js statt sandboxed iframe

Chromium/Brave zeigte leeren PDF-Bereich (PDFium/MimeHandlerView
verweigert Rendering in sandbox="allow-scripts" ohne allow-same-origin,
Firefox war nicht betroffen). allow-same-origin nachzurüsten kam nicht
infrage: Blob-URLs erben den App-Origin, die Kombination hätte die
Sandbox faktisch aufgehoben (PROJ-61-Kontext).

Neu: PdfCanvasPreview.tsx rendert PDF via pdf.js selbst nach <canvas>,
kein iframe/eingebettetes Dokument mehr nötig. Parsing im Worker, kein
PDF-eigenes JavaScript, kein Text-/Annotationslayer im DOM. Seitenlimit
30, eigener Fehlerzweig für passwortgeschützte/beschädigte PDFs.

Neue Dependency pdfjs-dist@6.2.108 - Restrisiko: PDF-Parsing läuft
jetzt im App-Origin statt im Browser-Viewer-Prozess.

Noch nicht im Browser verifiziert, Status bleibt In Review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WapWkrQusDuBMhaN8WyuXB
This commit is contained in:
sysops
2026-08-06 19:14:02 +02:00
co-authored by Claude Sonnet 5
parent ca693ded1b
commit 1ad1132006
5 changed files with 490 additions and 24 deletions
+30 -18
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { Download, Loader2 } from "lucide-react";
import { downloadMailAttachment, type MailAttachment } from "@/lib/api";
import { PdfCanvasPreview } from "@/components/mail/PdfCanvasPreview";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
@@ -27,10 +28,10 @@ import {
* - SVG ist absichtlich NICHT in der Whitelist (SVG kann Skripte enthalten).
* - Der Server-Content-Type wird nur bei Anhaengen OHNE Dateiendung als Hinweis
* zur Auswahl aus derselben Whitelist genutzt (BUG-81-6), nie zum Rendern.
* - PDFs laufen in einem sandboxed iframe MIT allow-scripts, aber ohne
* allow-same-origin: der Frame hat dadurch einen eigenen, undurchsichtigen
* Origin und keinen Zugriff auf Cookies/DOM der App. allow-scripts ist noetig,
* weil die browsereigenen PDF-Viewer sonst nicht rendern.
* - PDFs werden gar nicht mehr in einem iframe angezeigt, sondern von pdf.js
* nach Canvas gerendert (siehe PdfCanvasPreview, BUG-81-3). Damit entfaellt
* die Frage nach dem Sandbox-Attribut komplett: es gibt kein eingebettetes
* Dokument, das Rechte erben koennte.
* - Es entsteht kein oeffentlicher Link: die Blob-URL lebt nur im Tab und wird
* beim Schliessen des Dialogs wieder freigegeben.
*/
@@ -146,18 +147,23 @@ export function AttachmentPreviewDialog({
const isLarge = attachment.size > PREVIEW_SIZE_WARN_BYTES;
const [objectUrl, setObjectUrl] = useState<string | null>(null);
// Nur fuer den PDF-Pfad: pdf.js rendert aus dem Blob, nicht aus einer URL.
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirmedLarge, setConfirmedLarge] = useState(false);
// Blob-URL zuverlaessig freigeben, auch bei Unmount waehrend des Ladens.
const urlRef = useRef<string | null>(null);
const loadedRef = useRef(false);
const revoke = useCallback(() => {
if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
}
loadedRef.current = false;
setObjectUrl(null);
setPdfBlob(null);
}, []);
const load = useCallback(async () => {
@@ -167,9 +173,15 @@ export function AttachmentPreviewDialog({
const { blob } = await downloadMailAttachment(mailId, attachment.index);
// Erzwungener MIME-Typ statt Server-Angabe (siehe Security-Hinweis oben).
const safeBlob = new Blob([blob], { type: forcedMimeType(attachment) });
const url = URL.createObjectURL(safeBlob);
urlRef.current = url;
setObjectUrl(url);
loadedRef.current = true;
if (safeBlob.type === "application/pdf") {
// PDF: kein iframe, keine Blob-URL — pdf.js rendert direkt nach Canvas.
setPdfBlob(safeBlob);
} else {
const url = URL.createObjectURL(safeBlob);
urlRef.current = url;
setObjectUrl(url);
}
} catch (e) {
setError(
e instanceof Error
@@ -194,7 +206,8 @@ export function AttachmentPreviewDialog({
if (kind === "unsupported") return;
if (isLarge && !confirmedLarge) return;
if (error) return;
if (urlRef.current || loading) return;
// loadedRef deckt beide Pfade ab (Blob-URL fuer Bilder, Blob fuer PDF).
if (loadedRef.current || loading) return;
void load();
}, [open, kind, isLarge, confirmedLarge, error, loading, load]);
@@ -283,17 +296,16 @@ export function AttachmentPreviewDialog({
)}
{/*
allow-scripts ohne allow-same-origin: der Frame laeuft in einem
eigenen, undurchsichtigen Origin (kein Zugriff auf Cookies/DOM der
App). Skripte sind noetig, weil die eingebauten PDF-Viewer der
Browser sonst nicht rendern.
BUG-81-3: kein iframe mehr fuer PDF. Chromiums PDFium-Viewer
rendert in einem sandboxed iframe ohne allow-same-origin still
gar nichts (leere weisse Flaeche), Firefox schon. pdf.js rendert
stattdessen selbst nach Canvas — browserunabhaengig und ohne die
Sandbox aufweichen zu muessen. Siehe PdfCanvasPreview.
*/}
{!loading && !error && objectUrl && kind === "pdf" && (
<iframe
src={objectUrl}
title={`Vorschau: ${attachment.filename}`}
sandbox="allow-scripts"
className="h-[70vh] w-full border-0 bg-white"
{!loading && !error && pdfBlob && kind === "pdf" && (
<PdfCanvasPreview
blob={pdfBlob}
filename={attachment.filename}
/>
)}
</>
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Loader2 } from "lucide-react";
/**
* PROJ-81 / BUG-81-3: PDF-Vorschau via pdf.js (pdfjs-dist).
*
* Warum nicht der browsereigene Viewer im iframe:
* Chromiums PDFium laeuft als MimeHandlerView-Extension und braucht dafuer
* Same-Origin-Zugriff. In einem iframe mit sandbox="allow-scripts" OHNE
* allow-same-origin verweigert er sich still — genau das beobachtete Symptom
* (leere weisse Flaeche, kein Fehler). allow-same-origin nachzuruesten waere
* der falsche Weg: eine Blob-URL erbt den Origin der erzeugenden Seite, mit
* allow-same-origin waere der Frame also NICHT isoliert, sondern volles
* App-Origin (inkl. Zugriff auf parent.document und damit Aufhebung der
* eigenen Sandbox). Siehe Feature-Spec PROJ-81, Abschnitt BUG-81-3.
*
* pdf.js rendert stattdessen selbst nach <canvas>. Kein iframe, kein
* Fremd-Origin, keine Abhaengigkeit vom Sandbox-Verhalten des Browsers,
* identisches Verhalten in Chromium und Firefox.
*
* Haertung:
* - PDF-eigenes JavaScript wird nicht ausgefuehrt: das Scripting-Sandbox-Bundle
* (pdf.sandbox) wird gar nicht erst geladen, und `getDocument` bekommt keine
* Scripting-Option. pdfjs-dist v6 kennt `isEvalSupported` nicht mehr, weil
* eval() dort bereits vollstaendig entfernt wurde.
* - Das Parsen laeuft im Worker, nicht im Haupt-Thread.
* - Es wird ausschliesslich in Canvas gezeichnet, kein HTML aus dem PDF ins DOM
* (keine Textlayer-/Annotationslayer-Nutzung).
*/
/** Obergrenze, damit ein PDF mit sehr vielen Seiten den Tab nicht blockiert. */
const MAX_RENDERED_PAGES = 30;
export interface PdfCanvasPreviewProps {
/** Bereits geladener Anhang mit erzwungenem MIME-Typ application/pdf. */
blob: Blob;
filename: string;
}
export function PdfCanvasPreview({ blob, filename }: PdfCanvasPreviewProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [rendering, setRendering] = useState(true);
const [error, setError] = useState<string | null>(null);
const [truncatedAt, setTruncatedAt] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
const container = containerRef.current;
if (!container) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let doc: any = null;
async function render() {
setRendering(true);
setError(null);
setTruncatedAt(null);
try {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url
).toString();
const data = await blob.arrayBuffer();
if (cancelled) return;
doc = await pdfjs.getDocument({ data }).promise;
if (cancelled || !container) return;
container.replaceChildren();
const total = doc.numPages as number;
const limit = Math.min(total, MAX_RENDERED_PAGES);
for (let pageNo = 1; pageNo <= limit; pageNo++) {
const page = await doc.getPage(pageNo);
if (cancelled || !container) return;
const unscaled = page.getViewport({ scale: 1 });
const available = container.clientWidth || 800;
// Auf Containerbreite skalieren, aber nicht groesser als noetig.
const scale = Math.min(available / unscaled.width, 2);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
const ratio = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * ratio);
canvas.height = Math.floor(viewport.height * ratio);
// Breite/Hoehe des Bitmaps ergeben sich aus dem PDF, die Darstellung
// regelt Tailwind (w-full h-auto) — keine inline styles.
canvas.className = "mb-3 w-full h-auto shadow-sm";
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", `${filename}, Seite ${pageNo}`);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas wird von diesem Browser nicht unterstützt.");
ctx.scale(ratio, ratio);
container.appendChild(canvas);
await page.render({ canvas, canvasContext: ctx, viewport }).promise;
page.cleanup();
if (cancelled) return;
}
if (total > limit) setTruncatedAt(limit);
} catch (e) {
if (cancelled) return;
setError(
e instanceof Error
? e.message
: "PDF konnte nicht dargestellt werden."
);
} finally {
if (!cancelled) setRendering(false);
}
}
void render();
return () => {
cancelled = true;
// Worker-/Speicherressourcen des Dokuments freigeben.
void doc?.destroy?.();
container?.replaceChildren();
};
}, [blob, filename]);
return (
<div className="h-[70vh] overflow-auto bg-neutral-100 p-3">
{rendering && (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-neutral-700">
<Loader2 className="h-4 w-4 animate-spin" />
PDF wird gerendert
</div>
)}
{error && (
<div className="px-2 py-6 text-center text-sm text-neutral-800">
<p className="font-medium">PDF konnte nicht angezeigt werden.</p>
<p className="mt-1">{error}</p>
<p className="mt-1">
Möglich bei passwortgeschützten oder beschädigten Dateien. Die Datei
kann weiterhin heruntergeladen werden.
</p>
</div>
)}
<div ref={containerRef} />
{truncatedAt !== null && (
<p className="px-2 py-3 text-center text-sm text-neutral-700">
Vorschau auf die ersten {truncatedAt} Seiten begrenzt. Für das
vollständige Dokument bitte herunterladen.
</p>
)}
</div>
);
}