BentoPDF for bau-pm, without third-party hosts (bau-pm BAU-119)

BentoPDF v2.8.8 built from source at its pinned commit with BentoPDF's own
air-gapped build options, every module it would load from a CDN vendored and
checked against vendor.lock, a Content-Security-Policy without a single host,
digital-sign-pdf and timestamp-pdf disabled because both need a third party,
and the hand-off glue on every page: the AGPL source link, the hand-in of a
file from bau-pm through a one-minute cookie into sessionStorage, confirmed by
the tool's reaction, the hand-back of a result from every window, and the
signing tool's flatten switch locked on.

The zero-host gate drives all 117 enabled tool pages in Chromium and fails on
any request to another origin, any CSP violation, a CSP that names a host, and
a disabled id that names no page. Green on this image (3 min 52 s on a private
network), red on the stock upstream image (115 findings) and on a misspelled
id. CI runs it on every push, the release workflow before it pushes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
magdevandClaude Opus 5.5 committed 2026-10-10 19:36:02 +02:00
commit 47138ddfbe
19 files changed
+1733

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
# bau-pm-pdf-tools
[BentoPDF](https://github.com/alam00000/bentopdf) for [bau-pm](https://src.bundespruefstelle.ch/magdev/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):
```sh
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`.