From 70dc47d25c87ecd26be5d6ea451fee80db0f7c45 Mon Sep 17 00:00:00 2001 From: alam00000 Date: Mon, 19 Oct 2026 13:17:07 +0530 Subject: [PATCH] feat: update GitHub Actions workflow for PDF engine and viewer --- .github/workflows/cla.yml | 4 +- .github/workflows/update-bentopdf-viewer.yml | 108 ++++++-- nginx.conf | 2 +- public/sw.js | 49 ++-- src/js/logic/flatten-pdf-page.ts | 11 +- src/js/logic/remove-annotations-page.ts | 9 +- src/js/logic/remove-metadata-page.ts | 9 +- src/js/logic/validate-signature-pdf.ts | 97 ++++++-- src/js/utils/load-pdf-document.ts | 24 +- src/js/utils/redact-document-data.ts | 230 ++++++++++++++++++ src/js/utils/sanitize.ts | 17 +- src/js/workflow/nodes/flatten-node.ts | 9 +- src/js/workflow/nodes/redact-node.ts | 8 +- .../workflow/nodes/remove-annotations-node.ts | 9 +- src/tests/redact-document-data.test.ts | 210 ++++++++++++++++ src/tests/validate-signature-trust.test.ts | 132 ++++++++++ 16 files changed, 840 insertions(+), 88 deletions(-) create mode 100644 src/js/utils/redact-document-data.ts create mode 100644 src/tests/redact-document-data.test.ts create mode 100644 src/tests/validate-signature-trust.test.ts diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 20f831ef..a855e050 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -11,7 +11,6 @@ permissions: actions: write contents: write pull-requests: write - statuses: write jobs: cla-check: @@ -28,10 +27,9 @@ jobs: ) steps: - name: "CLA Assistant" - uses: contributor-assistant/github-action@v2.6.1 + uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PERSONAL_ACCESS_TOKEN: ${{ secrets.CLA_ASSISTANT_TOKEN }} with: path-to-signatures: 'signatures/cla.json' path-to-document: 'https://github.com/${{ github.repository }}/blob/main/ICLA.md' diff --git a/.github/workflows/update-bentopdf-viewer.yml b/.github/workflows/update-bentopdf-viewer.yml index 7f0136ca..c8a44e2d 100644 --- a/.github/workflows/update-bentopdf-viewer.yml +++ b/.github/workflows/update-bentopdf-viewer.yml @@ -6,8 +6,7 @@ on: - cron: '17 4 * * *' permissions: - contents: write - pull-requests: write + contents: read concurrency: group: update-engine @@ -18,12 +17,19 @@ env: HUSKY: 0 jobs: - update: + build: runs-on: ubuntu-latest if: github.repository == 'alam00000/bentopdf' + permissions: + contents: read + outputs: + run: ${{ steps.gate.outputs.run }} + version: ${{ steps.gate.outputs.version }} steps: - name: Checkout repo uses: actions/checkout@v4 + with: + persist-credentials: false - name: Read current upstream marker id: current @@ -40,13 +46,21 @@ jobs: - name: Should update? id: gate + env: + CURRENT: ${{ steps.current.outputs.version }} + UPSTREAM: ${{ steps.upstream.outputs.version }} run: | - if [ "${{ steps.upstream.outputs.version }}" = "${{ steps.current.outputs.version }}" ]; then + if ! [[ "$UPSTREAM" =~ ^[0-9a-f]{12}$ ]]; then + echo "::error::Unexpected fork HEAD: $UPSTREAM" + exit 1 + fi + echo "version=$UPSTREAM" >> "$GITHUB_OUTPUT" + if [ "$UPSTREAM" = "$CURRENT" ]; then echo "run=false" >> "$GITHUB_OUTPUT" - echo "Already on ${{ steps.current.outputs.version }}." + echo "Already on $CURRENT." else echo "run=true" >> "$GITHUB_OUTPUT" - echo "Updating ${{ steps.current.outputs.version }} -> ${{ steps.upstream.outputs.version }}" + echo "Updating $CURRENT -> $UPSTREAM" fi - name: Setup Node @@ -73,9 +87,10 @@ jobs: - name: Pack the engine if: steps.gate.outputs.run == 'true' id: engine + env: + SHA: ${{ steps.gate.outputs.version }} run: | set -euo pipefail - SHA=${{ steps.upstream.outputs.version }} VENDOR=./_fork/packages/pdfium/src/vendor STAGE=$(mktemp -d)/package mkdir -p "$STAGE" @@ -107,9 +122,10 @@ jobs: - name: Pack the viewer without a second engine if: steps.gate.outputs.run == 'true' id: viewer + env: + SHA: ${{ steps.gate.outputs.version }} run: | set -euo pipefail - SHA=${{ steps.upstream.outputs.version }} WORK=$(mktemp -d) (cd ./_fork && npm pack ./viewers/snippet --pack-destination "$WORK" >/dev/null) TMP=$(mktemp -d) @@ -137,14 +153,17 @@ jobs: - name: Point dependencies at the new tarballs if: steps.gate.outputs.run == 'true' + env: + ENGINE_TARBALL: ${{ steps.engine.outputs.tarball }} + VIEWER_TARBALL: ${{ steps.viewer.outputs.tarball }} run: | node -e " const fs = require('fs'); const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); - pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.argv[1]; - pkg.dependencies['bentopdf-viewer'] = 'file:' + process.argv[2]; + pkg.dependencies['bentopdf-pdfium'] = 'file:' + process.env.ENGINE_TARBALL; + pkg.dependencies['bentopdf-viewer'] = 'file:' + process.env.VIEWER_TARBALL; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n'); - " "${{ steps.engine.outputs.tarball }}" "${{ steps.viewer.outputs.tarball }}" + " - name: Cleanup upstream clone if: steps.gate.outputs.run == 'true' @@ -174,19 +193,66 @@ jobs: - name: Write upstream marker if: steps.gate.outputs.run == 'true' + env: + SHA: ${{ steps.gate.outputs.version }} run: | - echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-viewer/.upstream-version - echo "${{ steps.upstream.outputs.version }}" > vendor/bentopdf-pdfium/.upstream-version + echo "$SHA" > vendor/bentopdf-viewer/.upstream-version + echo "$SHA" > vendor/bentopdf-pdfium/.upstream-version + + - name: Upload update + if: steps.gate.outputs.run == 'true' + uses: actions/upload-artifact@v4 + with: + name: engine-update + include-hidden-files: true + retention-days: 1 + path: | + package.json + package-lock.json + vendor/bentopdf-pdfium/ + vendor/bentopdf-viewer/ + + pull-request: + runs-on: ubuntu-latest + needs: build + if: needs.build.outputs.run == 'true' + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Download update + uses: actions/download-artifact@v4 + with: + name: engine-update + path: ${{ runner.temp }}/engine-update + + - name: Apply update + id: apply + env: + UPDATE: ${{ runner.temp }}/engine-update + run: | + set -euo pipefail + rm -rf vendor/bentopdf-pdfium vendor/bentopdf-viewer + mkdir -p vendor + cp -R "$UPDATE/vendor/bentopdf-pdfium" "$UPDATE/vendor/bentopdf-viewer" vendor/ + cp "$UPDATE/package.json" "$UPDATE/package-lock.json" . + { + echo 'checksums<> "$GITHUB_OUTPUT" - name: Create Pull Request - if: steps.gate.outputs.run == 'true' - uses: peter-evans/create-pull-request@v6 + uses: peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c # v6.1.0 with: - commit-message: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}' - title: 'build(deps): bump PDF engine and viewer to ${{ steps.upstream.outputs.version }}' + commit-message: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}' + title: 'build(deps): bump PDF engine and viewer to ${{ needs.build.outputs.version }}' body: | Both vendored packages built from one clone of the viewer fork at - `${{ steps.upstream.outputs.version }}`, so the text editor and the + `${{ needs.build.outputs.version }}`, so the text editor and the PDF editor run the same engine. - `bentopdf-pdfium` — the engine wasm, packed from the fork's published `src/vendor` @@ -194,5 +260,11 @@ jobs: The viewer loads the shared engine through `EmbedPDF.init({ wasmUrl })`. CI verified a single wasm on disk, typecheck, and the test suite. + + Built in a read-only job. Tarball checksums: + + ``` + ${{ steps.apply.outputs.checksums }} + ``` branch: chore/update-pdf-engine delete-branch: true diff --git a/nginx.conf b/nginx.conf index 6045853b..2100881e 100644 --- a/nginx.conf +++ b/nginx.conf @@ -46,7 +46,7 @@ http { if ($request_uri ~ ^/en/?(\?.*)?$) { return 301 /$1; } - if ($request_uri ~ ^/en/(.+)$) { + if ($request_uri ~ ^/en/([A-Za-z0-9].*)$) { return 301 /$1; } diff --git a/public/sw.js b/public/sw.js index a9b0e92e..2787608e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -5,7 +5,7 @@ * Version: 1.1.0 */ -const CACHE_VERSION = 'bentopdf-v12'; +const CACHE_VERSION = 'bentopdf-v13'; const CACHE_NAME = `${CACHE_VERSION}-static`; const trustedCdnOrigins = new Set(['https://cdn.jsdelivr.net']); @@ -126,7 +126,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) { const fileName = url.pathname.split('/').pop(); try { - const cachedResponse = await findCachedFile(fileName, request.url); + const cachedResponse = await findCachedFile(request.url); if (cachedResponse) { // console.log('⚡ [Cache HIT] Instant load:', fileName); return cachedResponse; @@ -136,7 +136,11 @@ async function cacheFirstStrategyWithDedup(request, isCDN) { const networkResponse = await fetch(request); - if (networkResponse && networkResponse.status === 200) { + if ( + networkResponse && + networkResponse.status === 200 && + !networkResponse.redirected + ) { const clone = networkResponse.clone(); const buffer = await clone.arrayBuffer(); if (buffer.byteLength > 0) { @@ -163,8 +167,18 @@ async function cacheFirstStrategyWithDedup(request, isCDN) { if (localPath) { const localUrl = `${basePath}${localPath}${fileName}`; try { + const cachedLocal = await findCachedFile( + new URL(localUrl, location.origin).href + ); + if (cachedLocal) { + return cachedLocal; + } const fallbackResponse = await fetch(localUrl); - if (fallbackResponse && fallbackResponse.status === 200) { + if ( + fallbackResponse && + fallbackResponse.status === 200 && + !fallbackResponse.redirected + ) { const fbClone = fallbackResponse.clone(); const fbBuffer = await fbClone.arrayBuffer(); if (fbBuffer.byteLength > 0) { @@ -192,7 +206,7 @@ async function cacheFirstStrategyWithDedup(request, isCDN) { } } -async function findCachedFile(fileName, requestUrl) { +async function findCachedFile(requestUrl) { const cache = await caches.open(CACHE_NAME); const exactMatch = await cache.match(requestUrl); @@ -204,25 +218,6 @@ async function findCachedFile(fileName, requestUrl) { } await cache.delete(requestUrl); } - - const requests = await cache.keys(); - for (const req of requests) { - const reqUrl = new URL(req.url); - if ( - reqUrl.origin === location.origin && - reqUrl.pathname.split('/').pop() === fileName - ) { - const response = await cache.match(req); - if (response) { - const clone = response.clone(); - const buffer = await clone.arrayBuffer(); - if (buffer.byteLength > 0) { - return response; - } - await cache.delete(req); - } - } - } return null; } @@ -248,7 +243,11 @@ async function networkFirstStrategy(request) { try { const networkResponse = await fetch(request); - if (networkResponse && networkResponse.status === 200) { + if ( + networkResponse && + networkResponse.status === 200 && + !networkResponse.redirected + ) { const clone = networkResponse.clone(); const buffer = await clone.arrayBuffer(); if (buffer.byteLength > 0) { diff --git a/src/js/logic/flatten-pdf-page.ts b/src/js/logic/flatten-pdf-page.ts index a42f1614..e3e3d9f0 100644 --- a/src/js/logic/flatten-pdf-page.ts +++ b/src/js/logic/flatten-pdf-page.ts @@ -7,7 +7,10 @@ import { icons, createIcons } from 'lucide'; import JSZip from 'jszip'; import { deduplicateFileName } from '../utils/deduplicate-filename.js'; import { FlattenPdfState } from '@/types'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; const pageState: FlattenPdfState = { files: [], @@ -134,7 +137,7 @@ async function flattenPdf() { console.warn('Could not flatten annotations:', msg); } - const newPdfBytes = await pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); downloadFile( new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), `flattened_${file.name}` @@ -173,7 +176,9 @@ async function flattenPdf() { console.warn('Could not flatten annotations:', msg); } - const flattenedBytes = await pdfDoc.save(); + const flattenedBytes = await removeUnreferencedObjects( + await pdfDoc.save() + ); const zipEntryName = deduplicateFileName(file.name, usedNames); zip.file(zipEntryName, flattenedBytes); processedCount++; diff --git a/src/js/logic/remove-annotations-page.ts b/src/js/logic/remove-annotations-page.ts index bea92451..715f51a7 100644 --- a/src/js/logic/remove-annotations-page.ts +++ b/src/js/logic/remove-annotations-page.ts @@ -1,7 +1,10 @@ import { PDFDocument, PDFName } from 'pdf-lib'; import { createIcons, icons } from 'lucide'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; import { escapeHtml } from '../utils/helpers.js'; // State management @@ -142,7 +145,9 @@ async function processRemoveAnnotations() { } } - const newPdfBytes = await pageState.pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects( + await pageState.pdfDoc.save() + ); downloadFile( new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), pageState.file?.name || 'document.pdf' diff --git a/src/js/logic/remove-metadata-page.ts b/src/js/logic/remove-metadata-page.ts index ab5a60f8..d816456d 100644 --- a/src/js/logic/remove-metadata-page.ts +++ b/src/js/logic/remove-metadata-page.ts @@ -3,7 +3,10 @@ import { downloadFile, formatBytes } from '../utils/helpers.js'; import { PDFDocument, PDFName } from 'pdf-lib'; import { icons, createIcons } from 'lucide'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; -import { loadPdfDocument } from '../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../utils/load-pdf-document.js'; interface PageState { file: File | null; @@ -155,9 +158,9 @@ async function removeMetadata() { removeMetadataFromDoc(pdfDoc); - const newPdfBytes = await pdfDoc.save(); + const newPdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); downloadFile( - new Blob([newPdfBytes as BlobPart], { type: 'application/pdf' }), + new Blob([new Uint8Array(newPdfBytes)], { type: 'application/pdf' }), pageState.file?.name || 'document.pdf' ); showAlert('Success', 'Metadata removed successfully!', 'success', () => { diff --git a/src/js/logic/validate-signature-pdf.ts b/src/js/logic/validate-signature-pdf.ts index 7525aae7..1c1a46bc 100644 --- a/src/js/logic/validate-signature-pdf.ts +++ b/src/js/logic/validate-signature-pdf.ts @@ -113,6 +113,8 @@ export async function validateSignature( authenticatedAttributes?: forge.asn1.Asn1[]; signature?: string; signatureAlgorithm?: forge.asn1.Asn1[]; + issuer?: forge.asn1.Asn1; + serial?: string; }; }; @@ -121,7 +123,12 @@ export async function validateSignature( return result; } - const signerCert = p7.certificates[0] as forge.pki.Certificate; + const certificates = p7.certificates as forge.pki.Certificate[]; + const signerCert = findSignerCertificate( + certificates, + p7.rawCapture?.issuer, + p7.rawCapture?.serial + ); const subjectCN = signerCert.subject.getField('CN'); const subjectO = signerCert.subject.getField('O'); @@ -148,27 +155,11 @@ export async function validateSignature( // Check trust against provided certificate (cryptographic, not name/serial match) if (trustedCert) { try { - const trustedPem = forge.pki.certificateToPem(trustedCert); - const isSameCert = - forge.pki.certificateToPem(signerCert) === trustedPem; - - let cryptoIssued = false; - const chain = [ + result.isTrusted = chainsToTrustedCert( signerCert, - ...(p7.certificates as forge.pki.Certificate[]), - ]; - for (const cert of chain) { - try { - if (trustedCert.verify(cert)) { - cryptoIssued = true; - break; - } - } catch { - continue; - } - } - - result.isTrusted = isSameCert || cryptoIssued; + certificates, + trustedCert + ); } catch { result.isTrusted = false; } @@ -267,6 +258,70 @@ export async function validateSignature( return result; } +function findSignerCertificate( + certificates: forge.pki.Certificate[], + issuer: forge.asn1.Asn1 | undefined, + serial: string | undefined +): forge.pki.Certificate { + if (issuer && serial) { + const serialHex = forge.util.createBuffer(serial).toHex(); + const issuerDer = forge.asn1.toDer(issuer).getBytes(); + const match = certificates.find( + (cert) => + cert.serialNumber === serialHex && + forge.asn1 + .toDer(forge.pki.distinguishedNameToAsn1(cert.issuer)) + .getBytes() === issuerDer + ); + if (match) return match; + } + return certificates[0]; +} + +function isIssuedBy( + issuer: forge.pki.Certificate, + child: forge.pki.Certificate +): boolean { + try { + return issuer.verify(child); + } catch { + return false; + } +} + +function isCertificateAuthority(cert: forge.pki.Certificate): boolean { + const basicConstraints = cert.getExtension('basicConstraints') as + | { cA?: boolean } + | undefined; + return basicConstraints?.cA === true; +} + +function chainsToTrustedCert( + signerCert: forge.pki.Certificate, + certificates: forge.pki.Certificate[], + trustedCert: forge.pki.Certificate +): boolean { + const trustedPem = forge.pki.certificateToPem(trustedCert); + const visited = new Set(); + let current = signerCert; + + for (let depth = 0; depth < 10; depth++) { + const currentPem = forge.pki.certificateToPem(current); + if (currentPem === trustedPem || isIssuedBy(trustedCert, current)) { + return true; + } + if (visited.has(currentPem)) return false; + visited.add(currentPem); + + const parent = certificates.find( + (cert) => isCertificateAuthority(cert) && isIssuedBy(cert, current) + ); + if (!parent) return false; + current = parent; + } + return false; +} + export async function validatePdfSignatures( pdfBytes: Uint8Array, trustedCert?: forge.pki.Certificate diff --git a/src/js/utils/load-pdf-document.ts b/src/js/utils/load-pdf-document.ts index b287651c..ca421b70 100644 --- a/src/js/utils/load-pdf-document.ts +++ b/src/js/utils/load-pdf-document.ts @@ -4,13 +4,16 @@ import { initializeQpdf } from './helpers.js'; type LoadOptions = Parameters[1]; type PDFDocumentInstance = Awaited>; -async function repairPdfBytes(pdf: Uint8Array): Promise { +async function runQpdf( + pdf: Uint8Array, + args: string[] +): Promise { try { const qpdf = await initializeQpdf(); qpdf.FS.writeFile('/input.pdf', pdf); try { - qpdf.callMain(['/input.pdf', '--decrypt', '/output.pdf']); + qpdf.callMain(['/input.pdf', ...args, '/output.pdf']); } catch (e) { console.warn('[loadPdfDocument] qpdf repair warning:', e); } @@ -40,6 +43,23 @@ async function repairPdfBytes(pdf: Uint8Array): Promise { } } +function repairPdfBytes(pdf: Uint8Array): Promise { + return runQpdf(pdf, ['--decrypt']); +} + +export async function removeUnreferencedObjects( + pdf: Uint8Array | ArrayBuffer +): Promise { + const cleaned = await runQpdf( + pdf instanceof Uint8Array ? pdf : new Uint8Array(pdf), + [] + ); + if (!cleaned) { + throw new Error('Could not remove leftover data from the PDF.'); + } + return cleaned; +} + export async function loadPdfDocument( pdf: Uint8Array | ArrayBuffer, options?: LoadOptions diff --git a/src/js/utils/redact-document-data.ts b/src/js/utils/redact-document-data.ts new file mode 100644 index 00000000..751642d3 --- /dev/null +++ b/src/js/utils/redact-document-data.ts @@ -0,0 +1,230 @@ +import { + PDFArray, + PDFDict, + PDFHexString, + PDFName, + PDFObject, + PDFRawStream, + PDFRef, + PDFStream, + PDFString, + decodePDFRawStream, +} from 'pdf-lib'; +import type { PDFDocument } from 'pdf-lib'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from './load-pdf-document.js'; + +const ANNOTATION_TEXT_KEYS = ['Contents', 'RC', 'T', 'Subj']; + +function decodeText(value: PDFObject | undefined): string | null { + if (value instanceof PDFString || value instanceof PDFHexString) { + return value.decodeText(); + } + return null; +} + +function decodeStream(stream: PDFRawStream): Uint8Array | null { + try { + return decodePDFRawStream(stream).decode(); + } catch { + return null; + } +} + +function removeTextFromDocument(doc: PDFDocument, term: string): void { + const context = doc.context; + const needle = term.toLowerCase(); + const pattern = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + const contains = (text: string) => text.toLowerCase().includes(needle); + const valueContains = (value: PDFObject | undefined): boolean => { + if (value instanceof PDFArray) return value.asArray().some(valueContains); + const text = decodeText(value); + return text !== null && contains(text); + }; + const resolve = (value: PDFObject | undefined) => + value instanceof PDFRef ? context.lookup(value) : value; + + const matchedFiles = new Set(); + for (const [ref, object] of context.enumerateIndirectObjects()) { + if ( + !(object instanceof PDFRawStream) || + object.dict.get(PDFName.of('Type')) !== PDFName.of('EmbeddedFile') + ) { + continue; + } + const bytes = decodeStream(object); + if (!bytes) continue; + const latin1 = new TextDecoder('latin1').decode(bytes); + const utf16 = new TextDecoder('utf-16le').decode(bytes); + if (contains(latin1) || contains(utf16)) matchedFiles.add(ref.toString()); + } + + const isMatchedFileSpec = (value: PDFObject | undefined): boolean => { + const spec = resolve(value); + if (!(spec instanceof PDFDict)) return false; + const ef = resolve(spec.get(PDFName.of('EF'))); + if (!(ef instanceof PDFDict)) return false; + return ef + .values() + .some((v) => v instanceof PDFRef && matchedFiles.has(v.toString())); + }; + + const fieldValueContains = (widget: PDFDict): boolean => { + let node: PDFObject | undefined = widget; + for (let depth = 0; node instanceof PDFDict && depth < 32; depth++) { + if (valueContains(resolve(node.get(PDFName.of('V'))))) return true; + node = resolve(node.get(PDFName.of('Parent'))); + } + return false; + }; + + let clearedFieldAppearance = false; + for (const page of doc.getPages()) { + page.node.delete(PDFName.of('Thumb')); + const annots = page.node.Annots(); + if (!annots) continue; + + const removed = new Set(); + for (const entry of annots.asArray()) { + const annot = resolve(entry); + if (!(annot instanceof PDFDict)) continue; + const subtype = annot.get(PDFName.of('Subtype')); + + if (subtype === PDFName.of('Widget')) { + if (fieldValueContains(annot)) { + annot.delete(PDFName.of('AP')); + clearedFieldAppearance = true; + } + continue; + } + + const hasText = ANNOTATION_TEXT_KEYS.some((key) => + valueContains(resolve(annot.get(PDFName.of(key)))) + ); + const hasFile = + subtype === PDFName.of('FileAttachment') && + isMatchedFileSpec(annot.get(PDFName.of('FS'))); + if (hasText || hasFile) { + removed.add(entry.toString()); + const popup = annot.get(PDFName.of('Popup')); + if (popup) removed.add(popup.toString()); + } + } + + if (removed.size > 0) { + page.node.set( + PDFName.of('Annots'), + context.obj( + annots.asArray().filter((entry) => !removed.has(entry.toString())) + ) + ); + } + } + + if (clearedFieldAppearance) { + const acroForm = resolve(doc.catalog.get(PDFName.of('AcroForm'))); + if (acroForm instanceof PDFDict) { + acroForm.set(PDFName.of('NeedAppearances'), context.obj(true)); + } + } + + const pruneNameTree = (value: PDFObject | undefined, depth = 0) => { + const node = resolve(value); + if (!(node instanceof PDFDict) || depth > 32) return; + const names = resolve(node.get(PDFName.of('Names'))); + if (names instanceof PDFArray) { + const items = names.asArray(); + const kept: PDFObject[] = []; + for (let i = 0; i + 1 < items.length; i += 2) { + if (!isMatchedFileSpec(items[i + 1])) kept.push(items[i], items[i + 1]); + } + node.set(PDFName.of('Names'), context.obj(kept)); + } + const kids = resolve(node.get(PDFName.of('Kids'))); + if (kids instanceof PDFArray) { + for (const kid of kids.asArray()) pruneNameTree(kid, depth + 1); + } + }; + const pruneAssociatedFiles = (dict: PDFDict) => { + const list = resolve(dict.get(PDFName.of('AF'))); + if (!(list instanceof PDFArray)) return; + const kept = list.asArray().filter((spec) => !isMatchedFileSpec(spec)); + if (kept.length !== list.size()) { + dict.set(PDFName.of('AF'), context.obj(kept)); + } + }; + + if (matchedFiles.size > 0) { + const names = resolve(doc.catalog.get(PDFName.of('Names'))); + if (names instanceof PDFDict) { + pruneNameTree(names.get(PDFName.of('EmbeddedFiles'))); + } + for (const [, object] of context.enumerateIndirectObjects()) { + if (object instanceof PDFDict) pruneAssociatedFiles(object); + else if (object instanceof PDFStream) pruneAssociatedFiles(object.dict); + } + } + + const scrubValue = (value: PDFObject): PDFObject | null => { + const text = decodeText(value); + if (text !== null && contains(text)) { + return PDFHexString.fromText(text.replace(pattern, '')); + } + if (value instanceof PDFDict || value instanceof PDFArray) { + scrubContainer(value); + } + return null; + }; + + const scrubContainer = (container: PDFDict | PDFArray) => { + if (container instanceof PDFDict) { + for (const [key, value] of container.entries()) { + const replacement = scrubValue(value); + if (replacement) container.set(key, replacement); + } + } else { + container.asArray().forEach((value, index) => { + const replacement = scrubValue(value); + if (replacement) container.set(index, replacement); + }); + } + }; + + for (const [ref, object] of context.enumerateIndirectObjects()) { + if (object instanceof PDFStream) { + scrubContainer(object.dict); + const isMetadata = + object.dict.get(PDFName.of('Type')) === PDFName.of('Metadata'); + if (isMetadata && object instanceof PDFRawStream) { + const bytes = decodeStream(object); + const xml = bytes ? new TextDecoder('utf-8').decode(bytes) : null; + if (xml !== null && contains(xml)) { + context.assign( + ref, + context.flateStream(xml.replace(pattern, ''), { + Type: 'Metadata', + Subtype: 'XML', + }) + ); + } + } + } else if (object instanceof PDFDict || object instanceof PDFArray) { + scrubContainer(object); + } + } +} + +export async function redactDocumentData( + pdf: Uint8Array, + term?: string +): Promise { + const doc = await loadPdfDocument(pdf); + if (term) { + removeTextFromDocument(doc, term); + } else { + for (const page of doc.getPages()) page.node.delete(PDFName.of('Thumb')); + } + return removeUnreferencedObjects(await doc.save()); +} diff --git a/src/js/utils/sanitize.ts b/src/js/utils/sanitize.ts index 4fad95b8..13823cea 100644 --- a/src/js/utils/sanitize.ts +++ b/src/js/utils/sanitize.ts @@ -1,5 +1,8 @@ -import { PDFDocument, PDFName, PDFDict, PDFArray } from 'pdf-lib'; -import { loadPdfDocument } from './load-pdf-document.js'; +import { PDFDocument, PDFName, PDFDict, PDFArray, PDFStream } from 'pdf-lib'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from './load-pdf-document.js'; import type { PDFDocumentInternal } from '@/types'; function getCatalogDict(pdfDoc: PDFDocument): PDFDict { @@ -281,6 +284,14 @@ function removeEmbeddedFilesFromDoc(pdfDoc: PDFDocument) { } } + for (const [, object] of pdfDoc.context.enumerateIndirectObjects()) { + if (object instanceof PDFDict) { + object.delete(PDFName.of('AF')); + } else if (object instanceof PDFStream) { + object.dict.delete(PDFName.of('AF')); + } + } + const pdfDocInternal = pdfDoc as unknown as PDFDocumentInternal; if (pdfDocInternal.embeddedFiles && pdfDocInternal.embeddedFiles.length > 0) { pdfDocInternal.embeddedFiles = []; @@ -641,5 +652,5 @@ export async function sanitizePdf( } const savedBytes = await pdfDoc.save(); - return { pdfDoc, bytes: new Uint8Array(savedBytes) }; + return { pdfDoc, bytes: await removeUnreferencedObjects(savedBytes) }; } diff --git a/src/js/workflow/nodes/flatten-node.ts b/src/js/workflow/nodes/flatten-node.ts index 0791a5e9..26e40deb 100644 --- a/src/js/workflow/nodes/flatten-node.ts +++ b/src/js/workflow/nodes/flatten-node.ts @@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; import { flattenAnnotations } from '../../utils/flatten-annotations.js'; -import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../../utils/load-pdf-document.js'; export class FlattenNode extends BaseWorkflowNode { readonly category = 'Secure PDF' as const; @@ -39,11 +42,11 @@ export class FlattenNode extends BaseWorkflowNode { console.error('Flatten annotations error:', err); } - const pdfBytes = await pdfDoc.save(); + const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); return { type: 'pdf', document: pdfDoc, - bytes: new Uint8Array(pdfBytes), + bytes: pdfBytes, filename: input.filename.replace(/\.pdf$/i, '_flattened.pdf'), }; }), diff --git a/src/js/workflow/nodes/redact-node.ts b/src/js/workflow/nodes/redact-node.ts index d9175705..ec434f14 100644 --- a/src/js/workflow/nodes/redact-node.ts +++ b/src/js/workflow/nodes/redact-node.ts @@ -6,6 +6,7 @@ import { requirePdfInput, processBatch } from '../types'; import { loadPyMuPDF } from '../../utils/pymupdf-loader.js'; import { hexToRgb } from '../../utils/helpers.js'; import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { redactDocumentData } from '../../utils/redact-document-data.js'; import { wfError } from '../errors'; export class RedactNode extends BaseWorkflowNode { @@ -106,9 +107,14 @@ export class RedactNode extends BaseWorkflowNode { } } - const resultBytes = new Uint8Array(doc.save()); + const redactedBytes = new Uint8Array(doc.save()); doc.close(); + const resultBytes = await redactDocumentData( + redactedBytes, + mode === 'text' ? searchText : undefined + ); + const resultDoc = await loadPdfDocument(resultBytes); return { diff --git a/src/js/workflow/nodes/remove-annotations-node.ts b/src/js/workflow/nodes/remove-annotations-node.ts index 34ae9418..d7ca76c4 100644 --- a/src/js/workflow/nodes/remove-annotations-node.ts +++ b/src/js/workflow/nodes/remove-annotations-node.ts @@ -4,7 +4,10 @@ import { pdfSocket } from '../sockets'; import type { SocketData } from '../types'; import { requirePdfInput, processBatch } from '../types'; import { PDFName } from 'pdf-lib'; -import { loadPdfDocument } from '../../utils/load-pdf-document.js'; +import { + loadPdfDocument, + removeUnreferencedObjects, +} from '../../utils/load-pdf-document.js'; export class RemoveAnnotationsNode extends BaseWorkflowNode { readonly category = 'Edit & Annotate' as const; @@ -34,11 +37,11 @@ export class RemoveAnnotationsNode extends BaseWorkflowNode { } } - const pdfBytes = await pdfDoc.save(); + const pdfBytes = await removeUnreferencedObjects(await pdfDoc.save()); return { type: 'pdf', document: pdfDoc, - bytes: new Uint8Array(pdfBytes), + bytes: pdfBytes, filename: input.filename.replace(/\.pdf$/i, '_clean.pdf'), }; }), diff --git a/src/tests/redact-document-data.test.ts b/src/tests/redact-document-data.test.ts new file mode 100644 index 00000000..ec70584e --- /dev/null +++ b/src/tests/redact-document-data.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { + PDFArray, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFObject, + PDFRawStream, + PDFString, + decodePDFRawStream, +} from 'pdf-lib'; + +vi.mock('../js/utils/helpers.js', async () => { + const { default: createModule } = await import('@neslinesli93/qpdf-wasm'); + const wasmPath = resolve( + 'node_modules/@neslinesli93/qpdf-wasm/dist/qpdf.wasm' + ); + const options = { + locateFile: () => wasmPath, + wasmBinary: readFileSync(wasmPath), + print: () => {}, + printErr: () => {}, + }; + const qpdf = await createModule(options); + return { initializeQpdf: async () => qpdf }; +}); + +const { redactDocumentData } = + await import('../js/utils/redact-document-data.js'); + +const SECRET = 'SECRET-4471'; + +async function buildDocument(): Promise { + const doc = await PDFDocument.create(); + const page = doc.addPage([300, 300]); + const ctx = doc.context; + doc.setTitle(`Report for ${SECRET}`); + doc.setAuthor('Keep Author'); + + doc.catalog.set( + PDFName.of('Metadata'), + ctx.register( + ctx.flateStream(`${SECRET}`, { + Type: 'Metadata', + Subtype: 'XML', + }) + ) + ); + + const outlines = ctx.nextRef(); + const first = ctx.nextRef(); + const second = ctx.nextRef(); + ctx.assign( + first, + ctx.obj({ + Title: PDFString.of(`${SECRET} chapter`), + Parent: outlines, + Next: second, + }) + ); + ctx.assign( + second, + ctx.obj({ + Title: PDFString.of('Keep chapter'), + Parent: outlines, + Prev: first, + }) + ); + ctx.assign( + outlines, + ctx.obj({ Type: 'Outlines', First: first, Last: second, Count: 2 }) + ); + doc.catalog.set(PDFName.of('Outlines'), outlines); + + doc.catalog.set( + PDFName.of('StructTreeRoot'), + ctx.register( + ctx.obj({ + Type: 'StructTreeRoot', + K: ctx.obj({ + S: 'Figure', + Alt: PDFHexString.fromText(`Photo of ${SECRET}`), + }), + }) + ) + ); + + const secretNote = ctx.register( + ctx.obj({ + Type: 'Annot', + Subtype: 'Text', + Rect: [10, 10, 30, 30], + Contents: PDFString.of(`note about ${secret()}`), + }) + ); + const keepNote = ctx.register( + ctx.obj({ + Type: 'Annot', + Subtype: 'Text', + Rect: [40, 10, 60, 30], + Contents: PDFString.of('keep note'), + }) + ); + const appearance = ctx.register( + ctx.flateStream(`BT /Helv 10 Tf (${SECRET}) Tj ET`, { + Type: 'XObject', + Subtype: 'Form', + BBox: [0, 0, 100, 20], + }) + ); + const widget = ctx.register( + ctx.obj({ + Type: 'Annot', + Subtype: 'Widget', + FT: 'Tx', + T: PDFString.of('ssn'), + V: PDFString.of(SECRET), + Rect: [10, 100, 110, 120], + AP: { N: appearance }, + }) + ); + doc.catalog.set(PDFName.of('AcroForm'), ctx.obj({ Fields: [widget] })); + page.node.set(PDFName.of('Annots'), ctx.obj([secretNote, keepNote, widget])); + page.node.set( + PDFName.of('Thumb'), + ctx.register(ctx.flateStream(SECRET, { Width: 1, Height: 1 })) + ); + + await doc.attach( + new Uint8Array(Buffer.from(`payroll ${SECRET}`)), + 'payroll.txt', + { + mimeType: 'text/plain', + } + ); + await doc.attach(new Uint8Array(Buffer.from('harmless')), 'readme.txt', { + mimeType: 'text/plain', + }); + + return doc.save(); +} + +function secret() { + return SECRET.toLowerCase(); +} + +function collectText(value: PDFObject, out: string[]) { + if (value instanceof PDFString || value instanceof PDFHexString) { + out.push(value.decodeText()); + } else if (value instanceof PDFDict) { + for (const [, v] of value.entries()) collectText(v, out); + } else if (value instanceof PDFArray) { + for (const v of value.asArray()) collectText(v, out); + } +} + +async function allText(bytes: Uint8Array): Promise { + const doc = await PDFDocument.load(bytes, { updateMetadata: false }); + const out: string[] = [new TextDecoder('latin1').decode(bytes)]; + for (const [, object] of doc.context.enumerateIndirectObjects()) { + if (object instanceof PDFRawStream) { + collectText(object.dict, out); + try { + out.push( + new TextDecoder('latin1').decode(decodePDFRawStream(object).decode()) + ); + } catch { + continue; + } + } else { + collectText(object, out); + } + } + return out.join('\n'); +} + +describe('redactDocumentData', () => { + it('removes the redacted text from every place outside the page content', async () => { + const input = await buildDocument(); + expect((await allText(input)).toLowerCase()).toContain( + SECRET.toLowerCase() + ); + + const output = await redactDocumentData(input, secret()); + const text = await allText(output); + + expect(text.toLowerCase()).not.toContain(SECRET.toLowerCase()); + expect(text).toContain('Keep chapter'); + expect(text).toContain('keep note'); + expect(text).toContain('Keep Author'); + expect(text).toContain('harmless'); + expect(text).toContain('readme.txt'); + + const result = await PDFDocument.load(output, { updateMetadata: false }); + const annots = result.getPages()[0].node.Annots(); + expect(annots?.size()).toBe(2); + expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined(); + }); + + it('only drops thumbnails and leftovers when no search text is given', async () => { + const input = await buildDocument(); + const output = await redactDocumentData(input); + const result = await PDFDocument.load(output, { updateMetadata: false }); + expect(result.getPages()[0].node.get(PDFName.of('Thumb'))).toBeUndefined(); + expect(result.getTitle()).toBe(`Report for ${SECRET}`); + }); +}); diff --git a/src/tests/validate-signature-trust.test.ts b/src/tests/validate-signature-trust.test.ts new file mode 100644 index 00000000..60bae16e --- /dev/null +++ b/src/tests/validate-signature-trust.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import forge from 'node-forge'; +import { validateSignature } from '@/js/logic/validate-signature-pdf'; + +interface Identity { + cert: forge.pki.Certificate; + key: forge.pki.rsa.PrivateKey; +} + +let serial = 1; + +function makeIdentity( + commonName: string, + isCa: boolean, + issuer?: Identity +): Identity { + const keys = forge.pki.rsa.generateKeyPair(1024); + const cert = forge.pki.createCertificate(); + cert.publicKey = keys.publicKey; + cert.serialNumber = (serial++).toString(16).padStart(2, '0'); + cert.validity.notBefore = new Date(Date.now() - 86400000); + cert.validity.notAfter = new Date(Date.now() + 86400000 * 365); + const subject = [{ name: 'commonName', value: commonName }]; + cert.setSubject(subject); + cert.setIssuer(issuer ? issuer.cert.subject.attributes : subject); + cert.setExtensions([{ name: 'basicConstraints', cA: isCa }]); + cert.sign(issuer ? issuer.key : keys.privateKey, forge.md.sha256.create()); + return { cert, key: keys.privateKey }; +} + +function signContent( + content: Uint8Array, + signer: Identity, + bundle: forge.pki.Certificate[] +): Uint8Array { + const p7 = forge.pkcs7.createSignedData(); + p7.content = forge.util.createBuffer( + String.fromCharCode(...Array.from(content)) + ); + for (const cert of bundle) p7.addCertificate(cert); + p7.addSigner({ + key: signer.key, + certificate: signer.cert, + digestAlgorithm: forge.pki.oids.sha256, + authenticatedAttributes: [ + { type: forge.pki.oids.contentType, value: forge.pki.oids.data }, + { type: forge.pki.oids.messageDigest }, + ], + }); + p7.sign({ detached: true }); + const der = forge.asn1.toDer(p7.toAsn1()).getBytes(); + return Uint8Array.from(der, (c) => c.charCodeAt(0)); +} + +async function check( + signer: Identity, + bundle: forge.pki.Certificate[], + trusted: forge.pki.Certificate +) { + const pdfBytes = new TextEncoder().encode('%PDF-1.7 signed content'); + const contents = signContent(pdfBytes, signer, bundle); + return validateSignature( + { + index: 0, + contents, + byteRange: [0, pdfBytes.length, pdfBytes.length, 0], + }, + pdfBytes, + trusted + ); +} + +describe('validateSignature trust chain', () => { + const root = makeIdentity('Company Root CA', true); + const intermediate = makeIdentity('Company Issuing CA', true, root); + const employee = makeIdentity('Real Employee', false, root); + const chainedEmployee = makeIdentity('Chained Employee', false, intermediate); + const attacker = makeIdentity('John Smith', false); + + it('does not trust a self-made signer bundled with an unrelated company certificate', async () => { + const result = await check( + attacker, + [attacker.cert, employee.cert], + root.cert + ); + expect(result.signerName).toBe('John Smith'); + expect(result.cryptoVerified).toBe(true); + expect(result.isTrusted).toBe(false); + }); + + it('identifies the real signer even when another certificate is listed first', async () => { + const result = await check( + attacker, + [employee.cert, attacker.cert], + root.cert + ); + expect(result.signerName).toBe('John Smith'); + expect(result.cryptoVerified).toBe(true); + expect(result.isTrusted).toBe(false); + }); + + it('trusts a signer issued directly by the trusted certificate', async () => { + const result = await check(employee, [employee.cert], root.cert); + expect(result.cryptoVerified).toBe(true); + expect(result.isTrusted).toBe(true); + }); + + it('trusts a signer that chains to the trusted certificate through an intermediate CA', async () => { + const result = await check( + chainedEmployee, + [chainedEmployee.cert, intermediate.cert], + root.cert + ); + expect(result.cryptoVerified).toBe(true); + expect(result.isTrusted).toBe(true); + }); + + it('does not accept a non-CA certificate as an intermediate', async () => { + const issuedByLeaf = makeIdentity('Issued By Leaf', false, employee); + const result = await check( + issuedByLeaf, + [issuedByLeaf.cert, employee.cert], + root.cert + ); + expect(result.isTrusted).toBe(false); + }); + + it('trusts a signer whose own certificate is the trusted certificate', async () => { + const result = await check(attacker, [attacker.cert], attacker.cert); + expect(result.isTrusted).toBe(true); + }); +});