From 4248bc791a8fdfa5984bd1b08773eb4d6c01b6e7 Mon Sep 17 00:00:00 2001 From: magdev Date: Sun, 1 Nov 2026 09:48:16 +0100 Subject: [PATCH] The signing tool keeps no signature on the device (#1, bau-pm BAU-260) PDF.js's signature dialog saved every signature in localStorage under pdfjs.signature, "save" ticked on every open, for this origin rather than for a bau-pm user. Measured on v0.1.0: a typed and an uploaded signature were offered to a fresh page on the same browser, the way the next person on a shared tablet would get it, a Redmine logout included. Another browser profile saw nothing; the server never had anything. The glue's fifth duty, in every window before PDF.js's modules: delete the store (a device that saved signatures loses them on its first visit), refuse writes to that key, hold the save box unticked through a property on the element and hide it, and hide the dialog's Bild tab and its file picker, so a scanned signature never enters the dialog. test/signatures.mjs is the second gate, in CI and the release workflow: a browser seeded with a real saved entry (captured from v0.1.0's own store) is offered none, the dialog shows neither the box nor the tab, a typed signature is placed and not stored, a fresh page is offered nothing. Fed v0.1.0: 9 findings, exit 1. Against this image: 0, and the zero-host gate drove 117 tool pages with nothing leaving the origin. A signed PDF still exports with the signature on the page. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/ci.yml | 4 +- .gitea/workflows/release.yml | 4 +- CHANGELOG.md | 12 +++ README.md | 6 +- glue/handoff.js | 52 ++++++++++++- test/Dockerfile | 2 +- test/fixtures/saved-signature.json | 1 + test/signatures.mjs | 121 +++++++++++++++++++++++++++++ 8 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 test/fixtures/saved-signature.json create mode 100644 test/signatures.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index c1078e4..55bb168 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: timeout-minutes: 15 run: docker build -f test/Dockerfile -t bau-pm-pdf-tools-gate:ci . - - name: gate - nothing leaves the origin + - name: gate - nothing leaves the origin, no signature is kept timeout-minutes: 40 run: | net="bau-pm-pdf-tools-ci-${GITHUB_RUN_ID}" @@ -50,3 +50,5 @@ jobs: sleep 2 done docker run --rm --network "$net" bau-pm-pdf-tools-gate:ci node test/zero-hosts.mjs http://pdf-tools:8080 + # bau-pm §2.77: the signing tool keeps no signature on the device. + docker run --rm --network "$net" bau-pm-pdf-tools-gate:ci node test/signatures.mjs http://pdf-tools:8080 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2b8cce1..d9c4690 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -51,7 +51,7 @@ jobs: docker build --pull --build-arg VERSION="v$VERSION" \ -t "$REGISTRY/$IMAGE:$VERSION" -t "$REGISTRY/$IMAGE:latest" . - - name: gate - nothing leaves the origin + - name: gate - nothing leaves the origin, no signature is kept timeout-minutes: 50 run: | docker build -f test/Dockerfile -t bau-pm-pdf-tools-gate:release . @@ -65,6 +65,8 @@ jobs: sleep 2 done docker run --rm --network "$net" bau-pm-pdf-tools-gate:release node test/zero-hosts.mjs http://pdf-tools:8080 + # bau-pm §2.77: the signing tool keeps no signature on the device. + docker run --rm --network "$net" bau-pm-pdf-tools-gate:release node test/signatures.mjs http://pdf-tools:8080 - name: push run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd1258..54bfe14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Security + +- The signing tool keeps no signature on the device (issue #1, bau-pm BAU-260). PDF.js's signature dialog saved every signature in the browser's `localStorage`, with *Unterschrift speichern* ticked by default, for this address rather than for a bau-pm user, so the next person on a shared tablet or site office PC was offered the previous one's signature, typed, drawn or traced from an uploaded image, and logging out of bau-pm did not clear it. Every page now deletes that store and refuses its writes, and the dialog's save box is held unticked and hidden. Signatures a device saved before are deleted on its first visit after the update. + +### Removed + +- The *Bild* tab of the signing dialog, and its file picker: a scanned signature no longer enters the tool. Signing means typing or drawing it. + +### Added + +- A second gate, `test/signatures.mjs`, in CI and in the release workflow: nothing saved before is offered, nothing signed is stored, a fresh page is offered nothing, and the dialog shows neither the save box nor the image tab. Fed v0.1.0 it fails with 9 findings. + ## [0.1.1] - 2026-10-24 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 happened to have cached, so this image carries nginx 1.31.8 where v0.1.0 carried 1.31.6. Out of bau-pm's v0.22.3 security review. diff --git a/README.md b/README.md index eec703c..3903f25 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ This repository is public on purpose. BentoPDF is AGPL-3.0, and this is a modifi - **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. +- **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, the signing tool's flatten switch locked on, and no signature kept on the device. See the file's header for how each works. +- **No saved signatures, no image signatures.** PDF.js's signature dialog would save up to five signatures in the browser's `localStorage`, with *Unterschrift speichern* ticked by default, for this origin rather than for a bau-pm user: on a shared tablet the next person found the last one's signature one click away, a logout in bau-pm included (issue #1). The glue deletes the store on every page, refuses its writes, holds the box unticked and hides it, and hides the dialog's *Bild* tab, so a scanned signature never enters the tool. Signing means typing or drawing it, each time. 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. @@ -24,6 +25,8 @@ bau-pm runs the image as its `pdf-tools` service on an origin of its own (`PDF_T 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". +`test/signatures.mjs` is the second gate, run in the same step: on a browser that already holds a saved signature (a real entry, `test/fixtures/saved-signature.json`) the signing tool offers none and the store ends up empty; in its dialog the save box and the image tab are hidden; a typed signature, once placed, is not stored; and a fresh page, the next person, is offered nothing. Fed v0.1.0 it fails with 9 findings. + Locally (bridged containers need no internet for it): ```sh @@ -32,6 +35,7 @@ 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 run --rm --network pdf-gate bau-pm-pdf-tools-gate:ci node test/signatures.mjs http://pdf-tools:8080 docker rm -f -v pdf-gate-app && docker network rm pdf-gate ``` diff --git a/glue/handoff.js b/glue/handoff.js index 941eaae..5fef537 100644 --- a/glue/handoff.js +++ b/glue/handoff.js @@ -2,7 +2,7 @@ * * Loaded by every page of this image, the PDF.js viewers' pages inside the * tools included, as a classic script in , so it runs before BentoPDF's - * own modules. It does four things: + * own modules. It does five things: * * 1. A link to this image's source on every page, because the AGPL asks the * source of a modified version served over a network to be offered @@ -20,6 +20,14 @@ * blob is read inside click(), because the next line revokes its URL. * 4. For the signing tool, BentoPDF's "flatten" switched on and locked, so a * signature becomes part of the page and cannot be moved later. + * 5. No signature kept on the device. PDF.js's signature editor saves up to + * five signatures in localStorage, ticked by default, for this origin and + * not for a bau-pm user, so the next person on a shared tablet would find + * the last one's signature one click away, a Redmine logout included + * (bau-pm PLAN.md §2.77, Gitea #1). Every window deletes what is stored, + * refuses the store's writes, and in the viewer the "save" box is held off + * and hidden. The dialog's image tab is hidden too, so a scanned + * signature never enters the tool: signing means drawing or typing it. * * Nothing here talks to anything but this origin. The words of the hand-off * come from bau-pm in the user's language; only the source link has its own. @@ -31,6 +39,7 @@ var COOKIE = '__Host-bau_pm_handoff'; var API = '/bau-pm/handoff'; var SOURCE_URL = '__BAU_PM_SOURCE_URL__'; + var SIGNATURES = 'pdfjs.signature'; var isTop = window.top === window; // --- shared state -------------------------------------------------------- @@ -72,6 +81,9 @@ return nativeClick.apply(this, arguments); }; + forgetSignatures(); + ready(signatureDialog); + if (!isTop) { signFrame(); return; @@ -294,6 +306,44 @@ })(); } + // --- 5. no signature kept on the device --------------------------------- + + // Runs before PDF.js's modules in every window, so the viewer reads an empty + // store, and a device that saved signatures before this glue loses them on + // its first visit. The write is refused rather than undone afterwards: a + // signature that is never stored cannot be read by the next page either. + function forgetSignatures() { + try { + window.localStorage.removeItem(SIGNATURES); + var nativeSetItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (key) { + if (String(key) === SIGNATURES) { return undefined; } + return nativeSetItem.apply(this, arguments); + }; + } catch (e) { /* storage blocked: then nothing can be kept either */ } + } + + // The dialog sets the box to "save" each time it opens; a property of the + // element's own shadows the prototype's, so it reads unticked whatever is + // assigned to it. Only PDF.js's viewer has these elements. + function signatureDialog() { + var save = document.getElementById('addSignatureSaveCheckbox'); + if (!save) { return; } + Object.defineProperty(save, 'checked', { + configurable: true, + get: function () { return false; }, + set: function () {} + }); + hide(document.getElementById('addSignatureSaveContainer')); + hide(document.getElementById('addSignatureImageButton')); + var picker = document.getElementById('addSignatureFilePicker'); + if (picker) { picker.disabled = true; } + } + + function hide(element) { + if (element) { element.style.setProperty('display', 'none', 'important'); } + } + // --- 3. the hand-back's sheet -------------------------------------------- function button(label, kind) { diff --git a/test/Dockerfile b/test/Dockerfile index fd59df4..5d2ff88 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -1,4 +1,4 @@ -# The gate's own image (test/zero-hosts.mjs): Chromium and playwright-core, +# The gates' own image (test/zero-hosts.mjs, test/signatures.mjs): Chromium and playwright-core, # with the test files and the glue baked in, because the CI runner passes the # host's Docker socket through and a mounted workspace would be empty. FROM public.ecr.aws/docker/library/node:20-alpine diff --git a/test/fixtures/saved-signature.json b/test/fixtures/saved-signature.json new file mode 100644 index 0000000..4300e1f --- /dev/null +++ b/test/fixtures/saved-signature.json @@ -0,0 +1 @@ +{"description":"Vorher gespeichert","signatureData":"pZBLq1xFFIVXnXqeZz9v7o3GoE4EHSpB1KBCBEEQBXGi/8CxSgb6BxzpVCciOBbEiQNnkkSIkBglCl5R4315+3b3eVbVOafKdLpN5rqh4NuLWrX2rq857lQYA58S3K0zAFbtcwBeYsAnAJ4BcFGt+VEAb1DgMwBDAL9J4HMATwC4EgJfAHgFwCUBXNnohQCubt55UAHXNrpkwPUNv8jv8Tvhmh8G8BEFfgDwJIDv2JqfAnBe3ON35ZrP3eZGrXmV9RMHfgSwA+CqXDMDoAWwu+FXQ+CP2znj2zNcZsBfAM4C+IWveaU/Lda82vdteU+v1JoFgAsM2N94P6DAweYvh3LNlnbCTXbfFzxumYl/PnUQ2qznHKGddNv+DKUKSgd74Q3MTWNcgRO5iNNr9MTbrm/RBla5ye57ggsv2sH103txG3rGEJudfuzvoyz2oiFH0be21H1+x519HxRe636ZffOWW/pDf5PstloTyx0nr7PHuVII9QsfTvaJ803vDBrWCSjJo15or5tulWpPHztDOwnB2XYwe5lIQVUv6tFX0k3Be9Z0ZukLwpek7K3117NlaMI2JTFlit2vhITSKqczV2kdWEnCRCV0imEX27DmBYqusbZu97VqVLdjKIGiLPLCBAdtZ9GINq7PHb0GxrgEc9wk11KaYmIn82SfLnDidKtLm2M/8i7oYHRX8KNpMW5O+bEcpGFGEz+2YSubTlscRSXqTpekEvnWXmQjhDSSkSHGmpkpMBPLyHliXNt4yw/ehKRUQrbZ5cFJbKYtId2I8IiIltVB7cu2Xf4nz5IdJ77uO42O6p35BS9X+0mbXUryYT3qCOsjqCBQ4L3IyczaHHO2iIqs2uqGZBoNUzH1AzOax8d8huO+LquFm4t5ttw2Wz4iSqaDMAmmfmIzLTRpXWfccVrSBcpW18/eykpqSWXrpf0bBatFkRZbduzHPBnEGZ36UZuZsPJG9/MkZ3MU1mo46s7vZUVgUZkmb/+Pcy6WST5pdvyUx0kUUkWm/pSJrKq8XTmXbIGmr8rn/0xLqtHYZjXpoToeFlt2m6Qs5CLmGYn7UU6omepskZrgBEXfkI75ULAweADbOi3j2UMfz4s9b2TxWPMIJGccym59GWhveqcRwN05qc/UrcHvqfNBF+ZJGXfKx6I6a5My1aJitbPWL2gl86hKdepW9w/TXwfOkz6eK53au4pLZv/2s/jmyDumZR3VSUs8IQ6hD2WQ9kObNVEt6qDp27Zvgjw5TCvlpFcikD7rBjauQ01zVL3voxtZnXX/AA=="} \ No newline at end of file diff --git a/test/signatures.mjs b/test/signatures.mjs new file mode 100644 index 0000000..6115b71 --- /dev/null +++ b/test/signatures.mjs @@ -0,0 +1,121 @@ +// The signing tool keeps no signature on the device (bau-pm PLAN.md §2.77, +// Gitea #1): PDF.js's signature editor saves signatures in localStorage for +// this origin, not for a bau-pm user, so on a shared tablet the next person +// would find the last one's signature one click away. +// +// node test/signatures.mjs [chromium-path] +// +// 1. A device that already holds a saved signature (written the way an older +// image left it, from a page the glue does not run on) is offered none, +// and the store is empty afterwards. +// 2. In the signing dialog the "save" box and the image tab are hidden, and a +// signature typed and added with the dialog as it opens is not stored. +// 3. A fresh page on the same browser, the next person, is offered none. +// +// Exits 0 when nothing was kept, 1 otherwise, 2 when it could not run. Fed an +// image without the glue's fifth duty (v0.1.1 or older) it must fail. +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const base = (process.argv[2] || '').replace(/\/$/, ''); +const executablePath = process.argv[3] || process.env.CHROMIUM || '/usr/bin/chromium'; +if (!base) { + console.error('usage: signatures.mjs [chromium]'); + process.exit(2); +} + +const { chromium } = await import(process.env.PLAYWRIGHT_CORE || 'playwright-core'); +const KEY = 'pdfjs.signature'; +const failures = []; +const browser = await chromium.launch({ + executablePath, + args: ['--no-sandbox', `--unsafely-treat-insecure-origin-as-secure=${new URL(base).origin}`], +}); +// Wide enough for the viewer's toolbar to show the signature button. +const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + +async function viewer(page) { + await page.goto(`${base}/de/sign-pdf`, { waitUntil: 'networkidle' }); + await page.setInputFiles('#file-input', join(here, 'fixtures/probe.pdf')); + const frame = await (await page.waitForSelector('iframe', { timeout: 60000 })).contentFrame(); + await frame.waitForSelector('#editorSignatureButton:not([disabled])', { timeout: 60000 }); + return frame; +} + +const stored = (frame) => frame.evaluate((key) => window.localStorage.getItem(key), KEY); + +// The saved signatures the toolbar's menu offers, read from the menu itself. +async function offered(frame) { + await frame.click('#editorSignatureButton'); + await frame.waitForSelector('#editorSignatureAddSignature', { state: 'visible', timeout: 10000 }); + const titles = await frame.$$eval('#addSignatureDoorHanger .toolbarAddSignatureButtonContainer', (rows) => + rows.map((row) => row.querySelector('button')?.getAttribute('title') || row.textContent.trim())); + await frame.click('#editorSignatureButton'); + return titles; +} + +const shown = (frame, selector) => frame.$eval(selector, (e) => e.getClientRects().length > 0 && getComputedStyle(e).display !== 'none'); + +try { + // --- 1. a device that saved one before -------------------------------------- + // config.json is served without the glue, so this write is the browser's own. + // The entry is a real one, typed in v0.1.0's dialog and read back from its + // store: a made-up entry might simply fail to render in the menu, and the + // check would pass on an image that still offers saved signatures. + const seed = await context.newPage(); + await seed.goto(`${base}/config.json`); + await seed.evaluate(([key, entry]) => window.localStorage.setItem(key, JSON.stringify({ + '00000000-0000-4000-8000-000000000000': JSON.parse(entry), + })), [KEY, readFileSync(join(here, 'fixtures/saved-signature.json'), 'utf8')]); + await seed.close(); + + const a = await context.newPage(); + let frame = await viewer(a); + const before = await offered(frame); + if (before.length) failures.push(`a signature saved before is still offered: ${before.join(', ')}`); + if (await stored(frame)) failures.push('a signature saved before is still stored'); + + // --- 2. signing -------------------------------------------------------------- + // The toolbar button switches the signature mode on; the dialog's controls + // are clicked through the DOM, because the dialog is taller than the frame. + await frame.click('#editorSignatureButton'); + await frame.$eval('#editorSignatureAddSignature', (e) => e.click()); + await frame.waitForSelector('#addSignatureDialog[open]', { timeout: 10000 }); + if (await shown(frame, '#addSignatureSaveContainer')) failures.push('the dialog offers "save signature"'); + if (await shown(frame, '#addSignatureImageButton')) failures.push('the dialog offers the image tab'); + if (!(await frame.$eval('#addSignatureFilePicker', (e) => e.disabled))) failures.push('the image file picker is enabled'); + if (await frame.$eval('#addSignatureSaveCheckbox', (e) => e.checked)) failures.push('the "save" box reads ticked'); + await frame.$eval('#addSignatureTypeInput', (e) => { + e.value = 'Max Muster'; + e.dispatchEvent(new Event('input', { bubbles: true })); + }); + await frame.waitForSelector('#addSignatureAddButton:not([disabled])', { state: 'attached', timeout: 10000 }); + await frame.$eval('#addSignatureAddButton', (e) => e.click()); + await frame.waitForSelector('#addSignatureDialog[open]', { state: 'detached', timeout: 10000 }).catch(() => {}); + const placed = await frame.$$eval('.signatureEditor', (e) => e.length); + if (placed === 0) failures.push('the typed signature was not placed (the check below would prove nothing)'); + if (await stored(frame)) failures.push('the signature just added was stored'); + await a.close(); + + // --- 3. the next person ------------------------------------------------------ + const b = await context.newPage(); + frame = await viewer(b); + const after = await offered(frame); + if (after.length) failures.push(`the next page is offered: ${after.join(', ')}`); + if (await stored(frame)) failures.push('the next page finds a stored signature'); + await b.close(); +} catch (e) { + console.error(`signatures: could not run: ${e.message}`); + await browser.close(); + process.exit(2); +} + +await browser.close(); +if (failures.length) { + console.error(`signatures: ${failures.length} finding(s)`); + for (const f of failures) console.error(` - ${f}`); + process.exit(1); +} +console.log('signatures: nothing kept, nothing offered, no image tab');