diff --git a/docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md b/docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md new file mode 100644 index 00000000..fe058752 --- /dev/null +++ b/docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md @@ -0,0 +1,60 @@ +# ADR-028 — pdf.js wasm decoders are served same-origin; a future CSP must allow them + +**Date:** 2026-06-01 +**Status:** Accepted +**Issue:** #708 (scanned PDFs with CCITT/JBIG2 images render blank) +**Milestone:** Pre-prod read-path hardening + +--- + +## Context + +pdf.js 5.x moved the **JBIG2, CCITTFax, and JPEG2000 image decoders into +WebAssembly**. A single `jbig2.wasm` module decodes both JBIG2 and CCITTFax; +`openjpeg.wasm` decodes JPEG2000. These modules live in +`node_modules/pdfjs-dist/wasm/` and are not on the web path by default, and +`getDocument` will not load them unless it is given a `wasmUrl`. Without that, +bi-level black-and-white scans (CCITT G4 fax — ~16% of the archive) painted a +blank canvas in production while JPEG scans rendered fine. + +Two cross-cutting, long-lived constraints fall out of the fix and are not +obvious from reading any single file — hence this record. + +## Decision + +1. **Serve the pdf.js wasm from our own origin**, at the unversioned path + `/pdfjs-wasm/`, copied from `node_modules/pdfjs-dist/wasm/` into + `build/client/` at build time by `vite-plugin-static-copy` (a devDependency; + see `frontend/vite.config.ts`). `getDocument` is called with + `wasmUrl: '/pdfjs-wasm/'`. **Never point `wasmUrl` at a public CDN** — a + decoder on the core read path must not become a supply-chain RCE surface. + +2. **Any future `Content-Security-Policy` MUST include + `script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:`.** pdf.js + instantiates WebAssembly and runs its renderer in a worker created from a + `blob:` URL. A CSP without these directives silently re-breaks PDF rendering + for the exact class of documents #708 fixed. No CSP is set today + (`infra/caddy/Caddyfile` `(security_headers)`); the Caddyfile carries a + pointer to this ADR so the future CSP author cannot miss it. + +3. **The wasm shipping is guarded at build time.** `frontend/postbuild` + (`scripts/assert-pdfjs-wasm.mjs`) fails the build loudly if `jbig2.wasm` or + `openjpeg.wasm` is absent from `build/client/pdfjs-wasm/` — so a future + `pdfjs-dist` bump that renames or relocates the wasm cannot regress to a + blank canvas unnoticed. This runs in CI and in the Docker build stage. + +## Consequences + +- The decoders load from the same origin as the app — no third-party trust, no + SRI to manage, correct `Content-Type: application/wasm` served by + adapter-node. +- `/pdfjs-wasm/` is **not** content-hashed, so it must not be served + `immutable` — a revalidating cache avoids serving a stale `.wasm` against a + newer worker after a pdfjs upgrade. +- The CSP constraint is a standing obligation on whoever introduces a CSP. If + that work happens, this ADR and the Caddyfile note are the source of truth. +- No new container or external system is introduced, so the C4 L1/L2 diagrams + are unaffected; `/pdfjs-wasm/` is a static asset served by the existing + frontend container. +- Render/decode failures are no longer silent: the viewer surfaces a localized + message plus a working download link (see #708). diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 79384407..4488ca79 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -77,7 +77,11 @@ export default defineConfig( // defense (the CI regex stays as a backstop). For any legitimate use (e.g. // trusted server-rendered Markdown), suppress with an inline // `` and a justification. - 'svelte/no-at-html-tags': 'error' + 'svelte/no-at-html-tags': 'error', + // Reverse-tabnabbing (CWE-1022): any `target="_blank"` anchor must carry + // `rel="noopener noreferrer"`, or the opened page can hijack window.opener. + // Catches the pattern at lint time instead of relying on review. See #708. + 'svelte/no-target-blank': ['error', { allowReferrer: false, enforceDynamicLinks: 'always' }] } }, { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index a058a329..21fc48aa 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -126,6 +126,7 @@ "doc_label_summary": "Zusammenfassung", "doc_loading": "Lade Dokument...", "doc_download_link": "Direkter Download versuchen", + "doc_render_failed": "Dieser Scan ließ sich hier leider nicht anzeigen.", "doc_no_scan": "Kein Scan vorhanden", "persons_heading": "Personenverzeichnis", "persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 2c771571..55b12b51 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -126,6 +126,7 @@ "doc_label_summary": "Summary", "doc_loading": "Loading document...", "doc_download_link": "Try direct download", + "doc_render_failed": "This scan couldn’t be displayed here.", "doc_no_scan": "No scan available", "persons_heading": "Person directory", "persons_subtitle": "Browse the index of all recorded persons in the family archive.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index aab63403..cdebf5db 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -126,6 +126,7 @@ "doc_label_summary": "Resumen", "doc_loading": "Cargando documento...", "doc_download_link": "Intentar descarga directa", + "doc_render_failed": "No se pudo mostrar este escaneo aquí.", "doc_no_scan": "No hay escaneo disponible", "persons_heading": "Directorio de personas", "persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9738729a..06fdcec1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -54,6 +54,7 @@ "typescript-eslint": "^8.47.0", "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", + "vite-plugin-static-copy": "^4.1.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" } @@ -5078,6 +5079,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5189,6 +5217,19 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -6723,6 +6764,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-bun-module": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", @@ -7721,6 +7775,16 @@ "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -7861,6 +7925,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9534,6 +9611,93 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vite-plugin-static-copy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.0.tgz", + "integrity": "sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.6.0", + "p-map": "^7.0.4", + "picocolors": "^1.1.1", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": "^22.0.0 || >=24.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/sapphi-red" + }, + "peerDependencies": { + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-static-copy/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index bdd756db..0f135964 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite dev", "build": "vite build", + "postbuild": "node scripts/assert-pdfjs-wasm.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true", "postinstall": "patch-package", @@ -68,6 +69,7 @@ "typescript-eslint": "^8.47.0", "vite": "^7.3.3", "vite-plugin-devtools-json": "^1.0.0", + "vite-plugin-static-copy": "^4.1.0", "vitest": "^4.0.10", "vitest-browser-svelte": "^2.0.1" } diff --git a/frontend/scripts/assert-pdfjs-wasm.mjs b/frontend/scripts/assert-pdfjs-wasm.mjs new file mode 100644 index 00000000..6606fa74 --- /dev/null +++ b/frontend/scripts/assert-pdfjs-wasm.mjs @@ -0,0 +1,29 @@ +// Build-time guard for issue #708. The pdf.js wasm image decoders are copied +// into build/client/pdfjs-wasm/ by vite-plugin-static-copy. If a future +// pdfjs-dist bump moves or renames the wasm, the glob could silently copy +// nothing — and CCITT/JBIG2/JPEG2000 scans would render blank in production +// again with no test catching it (the bug is invisible to unit tests). Fail +// the build loudly instead. Runs after `npm run build` (incl. the Docker +// build stage) via the `postbuild` npm script. +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const dir = join(process.cwd(), 'build', 'client', 'pdfjs-wasm'); +// jbig2.wasm decodes JBIG2 + CCITTFax; openjpeg.wasm decodes JPEG2000. +const required = ['jbig2.wasm', 'openjpeg.wasm']; + +const missing = required.filter((f) => { + const p = join(dir, f); + return !existsSync(p) || statSync(p).size === 0; +}); + +if (missing.length > 0) { + console.error( + `\n[assert-pdfjs-wasm] MISSING from build output: ${missing.join(', ')}\n` + + `Expected non-empty files in ${dir}.\n` + + `The pdf.js wasm decoders did not ship — scanned PDFs would render blank.\n` + + `Check the vite-plugin-static-copy target in vite.config.ts and that\n` + + `node_modules/pdfjs-dist/wasm/ still contains these files. See issue #708.\n` + ); + process.exit(1); +} diff --git a/frontend/src/lib/document/DocumentViewer.svelte b/frontend/src/lib/document/DocumentViewer.svelte index b0ce8af6..aa8a575f 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte +++ b/frontend/src/lib/document/DocumentViewer.svelte @@ -72,6 +72,7 @@ let { {m.doc_download_link()} diff --git a/frontend/src/lib/document/DocumentViewer.svelte.test.ts b/frontend/src/lib/document/DocumentViewer.svelte.test.ts index a3982a0e..f5d70503 100644 --- a/frontend/src/lib/document/DocumentViewer.svelte.test.ts +++ b/frontend/src/lib/document/DocumentViewer.svelte.test.ts @@ -46,6 +46,20 @@ describe('DocumentViewer', () => { .toHaveAttribute('href', '/api/documents/d1/file'); }); + it('hardens the target=_blank download link with rel=noopener noreferrer (CWE-1022)', async () => { + render(DocumentViewer, { + props: { + ...baseProps, + doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' }, + error: 'Render failed' + } + }); + + await expect + .element(page.getByRole('link', { name: /direkter download/i })) + .toHaveAttribute('rel', 'noopener noreferrer'); + }); + it('omits the direct-download link in the error state when filePath is null', async () => { render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } }); diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte b/frontend/src/lib/document/viewer/PdfViewer.svelte index e9169c5d..5164178d 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte @@ -170,15 +170,37 @@ function handleAnnotationClick(id: string) {

Keine Datei vorhanden

{:else if renderer.error} -
-

Fehler beim Laden der PDF

+ +
{:else} diff --git a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts index e528b443..d964a1f9 100644 --- a/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/PdfViewer.svelte.test.ts @@ -3,9 +3,63 @@ import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import PdfViewer from './PdfViewer.svelte'; import { makeFakeLibLoader } from './testHelpers'; +import { m } from '$lib/paraglide/messages.js'; afterEach(cleanup); +// Document loads fine, but rendering the page rejects with a non-cancellation +// error — exactly the wasm-decode failure class from #708. Exercises the real +// renderCurrentPage path, not the load path. +function makeRenderFailingLibLoader() { + const page = { + getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }), + render: vi.fn().mockReturnValue({ + promise: Promise.reject(new Error('JBig2 failed to initialize')), + cancel: vi.fn() + }), + streamTextContent: vi.fn().mockReturnValue(new ReadableStream()) + }; + const lib = { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) }) + }), + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); + return vi.fn().mockResolvedValue([lib, { default: '' }] as const); +} + +describe('PdfViewer — render failure', () => { + it('shows the localized failure message and a download link when the page render rejects', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + libLoader: makeRenderFailingLibLoader() + }); + + await expect.element(page.getByText(m.doc_render_failed())).toBeVisible(); + await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible(); + // Announced to assistive tech, not a silent visual-only failure. + await expect.element(page.getByRole('alert')).toBeVisible(); + }); + + it('does not show the failure message when the page renders successfully', async () => { + render(PdfViewer, { + url: '/api/documents/test/file', + documentId: 'test', + libLoader: makeFakeLibLoader() + }); + + await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible(); + expect(document.body.textContent).not.toContain(m.doc_render_failed()); + }); +}); + describe('PdfViewer — empty / error states', () => { it('renders the no-file placeholder when url is empty', async () => { render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() }); diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts index 6b48c3f8..5cbbed2a 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.test.ts @@ -1,6 +1,27 @@ import { describe, it, expect, vi } from 'vitest'; import { createPdfRenderer } from './usePdfRenderer.svelte'; import { makeFakeLibLoader } from './testHelpers'; +import { m } from '$lib/paraglide/messages.js'; + +function makeRenderingLib(renderPromise: Promise): typeof import('pdfjs-dist') { + const page = { + getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }), + render: vi.fn().mockReturnValue({ promise: renderPromise, cancel: vi.fn() }), + streamTextContent: vi.fn().mockReturnValue(new ReadableStream()) + }; + return { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument: vi.fn().mockReturnValue({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) }) + }), + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); +} // Note: init() and loadDocument() require pdfjsLib (browser module). // These tests cover pure state logic only — bounds clamping and zoom limits. @@ -205,7 +226,58 @@ describe('createPdfRenderer', () => { expect(fakeLoader).toHaveBeenCalledOnce(); }); - it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => { + it('passes a non-null wasmUrl directory (ending in /) to getDocument, not a bare src string', async () => { + const getDocument = vi.fn().mockReturnValue({ + promise: Promise.resolve({ numPages: 1, getPage: vi.fn() }) + }); + const lib = { + GlobalWorkerOptions: { workerSrc: '' }, + getDocument, + TextLayer: class { + render() { + return Promise.resolve(); + } + cancel() {} + } + } as unknown as typeof import('pdfjs-dist'); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + await r.loadDocument('/api/documents/abc/file'); + + expect(getDocument).toHaveBeenCalledTimes(1); + const arg = getDocument.mock.calls[0][0] as { url?: string; wasmUrl?: string }; + expect(arg.url).toBe('/api/documents/abc/file'); + expect(typeof arg.wasmUrl).toBe('string'); + expect(arg.wasmUrl).not.toBe(''); + expect(arg.wasmUrl?.endsWith('/')).toBe(true); + }); + + it('renderCurrentPage sets a localized error when the render rejects (not silently blank)', async () => { + const lib = makeRenderingLib(Promise.reject(new Error('JBig2 failed to initialize'))); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + r.setElements(document.createElement('canvas'), document.createElement('div')); + await r.loadDocument('/x'); + await r.renderCurrentPage(); + + expect(r.error).toBe(m.doc_render_failed()); + }); + + it('renderCurrentPage does NOT set an error when the render is cancelled', async () => { + const cancelled = Object.assign(new Error('cancelled'), { + name: 'RenderingCancelledException' + }); + const lib = makeRenderingLib(Promise.reject(cancelled)); + const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const)); + await r.init(); + r.setElements(document.createElement('canvas'), document.createElement('div')); + await r.loadDocument('/x'); + await r.renderCurrentPage(); + + expect(r.error).toBeNull(); + }); + + it('loadDocument sets a localized error (not the raw pdf.js message) when getDocument rejects', async () => { const failingLib = { GlobalWorkerOptions: { workerSrc: '' }, getDocument: vi.fn().mockReturnValue({ @@ -222,6 +294,7 @@ describe('createPdfRenderer', () => { await r.init(); await r.loadDocument('/bad/path'); expect(r.loading).toBe(false); - expect(r.error).toBe('PDF not found'); + expect(r.error).toBe(m.doc_render_failed()); + expect(r.error).not.toContain('PDF not found'); }); }); diff --git a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts index 13315f8e..e7e9186b 100644 --- a/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts +++ b/frontend/src/lib/document/viewer/usePdfRenderer.svelte.ts @@ -1,10 +1,17 @@ import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; +import { m } from '$lib/paraglide/messages.js'; export type LibLoader = () => Promise; const defaultLibLoader: LibLoader = () => Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]); +// pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images via WebAssembly and +// needs to know where the .wasm modules are served. Must be a directory URL +// with a trailing slash — pdf.js appends `jbig2.wasm` etc. Served from our own +// origin by vite-plugin-static-copy (see vite.config.ts). See issue #708. +const WASM_URL = '/pdfjs-wasm/'; + export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { // Reactive state — exposed via getters let currentPage = $state(1); @@ -44,12 +51,14 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { totalPages = 0; try { - const loadingTask = pdfjsLib.getDocument(src); + const loadingTask = pdfjsLib.getDocument({ url: src, wasmUrl: WASM_URL }); const doc = await loadingTask.promise; pdfDoc = doc; totalPages = doc.numPages; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load PDF'; + } catch { + // Never surface the raw pdf.js message — show a localized failure + // that routes into the viewer's error UI (message + download link). + error = m.doc_render_failed(); } finally { loading = false; } @@ -99,6 +108,11 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { (e as { name: string }).name === 'RenderingCancelledException' ) return; + // A real decode/render failure (e.g. a wasm decoder that could not + // initialise) — surface a localized message instead of leaving a + // silent blank canvas. Never leak the raw pdf.js error text. + renderTask = null; + error = m.doc_render_failed(); return; } renderTask = null; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index bb4113c5..a0d1e977 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite'; import { defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; import { sveltekit } from '@sveltejs/kit/vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; export default defineConfig({ optimizeDeps: { @@ -42,6 +43,15 @@ export default defineConfig({ tailwindcss(), sveltekit(), devtoolsJson(), + // pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images in WebAssembly. + // Serve the wasm from our own origin at /pdfjs-wasm/ (referenced by + // getDocument's wasmUrl) — emitted into build/client/ so it survives the + // production Docker image, not just `npm run dev`. See issue #708. + viteStaticCopy({ + targets: [ + { src: 'node_modules/pdfjs-dist/wasm/*', dest: 'pdfjs-wasm', rename: { stripBase: true } } + ] + }), paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' diff --git a/infra/caddy/Caddyfile b/infra/caddy/Caddyfile index b3d1e971..8c0642bb 100644 --- a/infra/caddy/Caddyfile +++ b/infra/caddy/Caddyfile @@ -22,6 +22,11 @@ # XSS landing in a privileged origin: a payload cannot silently turn # on the microphone or read geolocation. Permissions-Policy "camera=(), microphone=(), geolocation=()" + # No Content-Security-Policy is set yet. When one is added, it MUST + # include `script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:` + # or the pdf.js WebAssembly image decoders (JBIG2/CCITTFax/JPEG2000) + # and worker will be blocked and scanned PDFs render blank. + # See #708 and docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md. -Server } }