Compare commits
3 Commits
e16b7402bd
...
420c0e3e10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
420c0e3e10 | ||
|
|
cb61e63b02 | ||
|
|
8eb321ccea |
60
docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md
Normal file
60
docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md
Normal file
@@ -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).
|
||||
@@ -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
|
||||
// `<!-- eslint-disable-next-line svelte/no-at-html-tags -->` 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' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"doc_label_summary": "Zusammenfassung",
|
||||
"doc_loading": "Lade Dokument...",
|
||||
"doc_download_link": "Direkter Download versuchen",
|
||||
"doc_render_failed": "Dieser Scan konnte nicht angezeigt werden.",
|
||||
"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.",
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"doc_label_summary": "Summary",
|
||||
"doc_loading": "Loading document...",
|
||||
"doc_download_link": "Try direct download",
|
||||
"doc_render_failed": "This scan could not be displayed.",
|
||||
"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.",
|
||||
|
||||
@@ -126,7 +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.",
|
||||
"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.",
|
||||
|
||||
@@ -178,12 +178,27 @@ function handleAnnotationClick(id: string) {
|
||||
role="alert"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg px-4 text-center text-ink-3"
|
||||
>
|
||||
<!-- A shape, not colour alone, signals the warning (WCAG 1.4.1). -->
|
||||
<svg
|
||||
class="h-10 w-10 text-red-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="font-sans text-base text-red-400">{m.doc_render_failed()}</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block py-2 font-sans text-sm text-primary underline hover:text-ink-2"
|
||||
class="inline-flex min-h-[44px] items-center px-3 py-2 font-sans text-sm text-primary underline hover:text-ink-2"
|
||||
>
|
||||
{m.doc_download_link()}
|
||||
</a>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
# 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 worker will be blocked and scanned PDFs render blank.
|
||||
# See #708 and docs/adr/028-pdfjs-wasm-decoders-and-csp-constraint.md.
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user