magdevandClaude Opus 5 ac6d57562b
CI / gate (push) Successful in 5m18s
Release / release (push) Successful in 4m42s
Release v0.1.1: the same BentoPDF on current packages
Nothing in this repository's own code changed. What changed is that a build
no longer ships whatever base image the runner had cached, so this image
carries nginx 1.31.8 where v0.1.0 carried 1.31.6 - past five October CVEs,
none of them reachable in a static file server configured like this one,
which is why --pull is the fix rather than a pin. Out of bau-pm's v0.22.3
security review, finding 41 (bau-pm BAU-191).

Gate, the same one CI runs, on this tree: shellcheck clean over scripts/;
the image built with --pull, BentoPDF v2.8.8 verified at its pinned commit,
2776 pages tagged with a CSP carrying no host; the zero-hosts gate drove all
117 tool pages against the running image and nothing left the origin; nginx
1.31.8 in the built image.

bau-pm still pins 0.1.0 in compose.yaml and .env.example, so this reaches a
running instance only once that pin moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 10:18:20 +02:00

bau-pm-pdf-tools

BentoPDF for bau-pm: about 120 PDF tools (edit, annotate, sign, fill forms, organise, merge, split, compress, convert, OCR) that run entirely in the browser, packaged so that a page of it talks to its own origin and to nobody else, plus the glue that hands a PDF from bau-pm into a tool and the result back.

This repository is public on purpose. BentoPDF is AGPL-3.0, and this is a modified version served over a network, so its source is offered to every user: each page of the running image links here, to the tag it was built from.

What is in the image

  • BentoPDF built from source at a pinned commit (BENTOPDF_REF and BENTOPDF_COMMIT in the Dockerfile; the build fails if the tag points elsewhere), with BentoPDF's own air-gapped build options: Simple Mode, German as the default language, every module path pointing at this image (/wasm/...), the CORS proxy switched off.
  • Every module BentoPDF would otherwise load from a CDN, served from /wasm/: PyMuPDF and Ghostscript from BentoPDF's own repository at that commit, CoherentPDF, Tesseract with deu, fra, ita and eng, the OCR text-layer font and the PDF editor's Latin, Arabic and Hebrew fallback fonts from npm. scripts/vendor.sh packs each one and refuses the build when the registry's integrity differs from vendor.lock. The editor's CJK fonts (30 to 42 MB each) are left out; a document that needs them asks this server and gets a 404, never a third party.
  • A Content-Security-Policy without a single host: BentoPDF's generated header file with every http(s): source taken out, img-src included (scripts/finish.sh, which fails the build if any host survives).
  • Two tools disabled in config/config.json, because they cannot work without a third party: digital-sign-pdf (X.509 signing fetches certificate chains through a CORS proxy) and timestamp-pdf (an RFC 3161 timestamp needs an external timestamp authority).
  • The glue, glue/handoff.js, added to every HTML page by scripts/finish.sh: the source link, the hand-in of a file from bau-pm, the hand-back of a result, and the signing tool's flatten switch locked on. See the file's header for how each works.

Nothing of BentoPDF's code is edited: its documented build arguments, its runtime config.json, its generated header file with fewer hosts, and our files beside its own.

How bau-pm uses it

bau-pm runs the image as its pdf-tools service on an origin of its own (PDF_TOOLS_ADDRESS, e.g. pdf.example.ch), behind its Caddy, which asks Redmine before serving any page, so only logged-in users get in. The design, the login gate and the hand-off protocol are bau-pm's (PLAN.md §2.50 there); this image only needs to be reachable on port 8080. It is static nginx: about 3 MB of memory, and it writes nothing but its temp directories, so it runs with a read-only root and three tmpfs mounts owned by uid 101 (see bau-pm's compose.yaml).

The gate

test/zero-hosts.mjs drives every tool page linked from the German overview in a phone-sized Chromium: it gives each a probe file its input accepts, presses its main button, and fails on any request to another origin and on any CSP violation (a blocked request is a tool that tried to call out). It also fails when the CSP names a host, and when an id in config.json does not name a real page or its page still offers the tool. CI runs it on every push (.gitea/workflows/ci.yml), and the release workflow runs it again on the image it is about to push.

It has teeth: fed the stock upstream image it fails with 115 findings (PyMuPDF, Pyodide and CoherentPDF from jsdelivr), and fed a misspelled disabled id it fails with "no page".

Locally (bridged containers need no internet for it):

docker build -t bau-pm-pdf-tools:ci .
docker build -f test/Dockerfile -t bau-pm-pdf-tools-gate:ci .
docker network create pdf-gate
docker run -d --name pdf-gate-app --network pdf-gate --network-alias pdf-tools bau-pm-pdf-tools:ci
docker run --rm --network pdf-gate bau-pm-pdf-tools-gate:ci node test/zero-hosts.mjs http://pdf-tools:8080
docker rm -f -v pdf-gate-app && docker network rm pdf-gate

Bumping BentoPDF

  1. Set BENTOPDF_REF and BENTOPDF_COMMIT in the Dockerfile to the new tag and its commit.
  2. Compare the module versions BentoPDF expects (package-lock.json for Tesseract, .env.production and src/js/utils/wasm-provider.ts for PyMuPDF, Ghostscript and CoherentPDF, src/js/config/editor-fonts.ts for the editor's fonts, src/js/config/font-mappings.ts for the OCR font) with vendor.lock and scripts/vendor.sh, and update both together: a new version needs its integrity from the registry (npm view <pkg>@<version> dist.integrity).
  3. Read the diff of scripts/generate-security-headers.mjs, nginx.conf and scripts/prepare-airgap.sh upstream: a new directive, a new CDN or a new module layout is where a bump can break the zero-host promise.
  4. Build, run the gate, and check the two disabled tools are still the only ones that need a third party.
  5. Note the bump in CHANGELOG.md, release, and pin the new tag in bau-pm (PDF_TOOLS_VERSION).

Known upstream issues (BentoPDF v2.8.8)

Measured on 2026-10-10 against the stock image as much as against this one, so they are upstream behaviour, not this packaging:

  • A signature drawn in the signing tool cannot be saved. With real touch input, pdf.js's serialisation fails (Cannot read properties of null (reading 'serialize')): with Flatten PDF on the export fails visibly, with it off pdf.js silently saves the unsigned original. A typed signature saves.
  • Flattening drops a signature on some PDFs. BentoPDF's flattenAnnotations removes any annotation without an appearance stream, and on a PDF rendered by rbpdf/TCPDF (bau_pm_preflight's Vorausmass sheet) the typed signature comes back without one. On a PDF pdf-lib has re-saved once, it is kept. With flatten off, the signature stays as an annotation.

bau_pm_preflight's tablet sign-off (its PRE-43) waits on these; see its PLAN.md §3.22.

Licence

AGPL-3.0, as BentoPDF (LICENSE). The vendored modules keep their own licences; see THIRD-PARTY.md.

S
Description
BentoPDF for bau-pm, without third-party hosts (AGPL-3.0)
Readme AGPL-3.0
77 KiB
1 Watchers 1 Forks
Languages
JavaScript 73%
Shell 14.1%
Dockerfile 12.9%