From f38e8b659e8b54253e2df84030d4d47d4fb31747 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Tue, 28 Jul 2026 14:04:21 +0200 Subject: [PATCH 1/3] Update the signatures-helpers to actually handle `MissingDataException`s The `PDFDocument.prototype.signatures` getter returns a (shadowed) Promise, however the way that it invokes various helper-methods can lead to *intermittent* failures to parse the signature data. These helper-methods will lookup a fair amount of Dictionary data, however any one of those cases could throw `MissingDataException` during document loading. To avoid having to re-factor those methods a lot, and adding a bunch more `pdfManager.ensureDoc()` calls, they are instead made asynchronous and the Dictionary lookups changed to use `Dict.prototype.getAsync` (similar to the existing `fieldObjects` handling). Technically this additional asynchronicity may be ever so slightly slower, however I don't think it matters in practice since: most PDFs don't have any signatures, the signature-UI is initialized lazily in the viewer, and finally fetching/parsing of signatures do not block rendering. Also, note how multiple values are being fetched in parallel in order to attempt to reduce overall asynchronicity. *Note:* The unit-test changes are essentially fixing pre-existing bugs, that this patch exposed, since the test-only `Dict` instances weren't able to fetch indirect objects. --- src/core/document.js | 62 +++++++++++++++++++++----------------- test/unit/document_spec.js | 57 +++++++++++++++++++++++++++-------- 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/src/core/document.js b/src/core/document.js index f5d72614c..b9ad8b899 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -1996,7 +1996,7 @@ class PDFDocument { return shadow(this, "fieldObjects", promise); } - #collectSignatureFields(fields, out, visitedRefs) { + async #collectSignatureFields(fields, out, visitedRefs) { if (!Array.isArray(fields)) { return; } @@ -2007,14 +2007,18 @@ class PDFDocument { } visitedRefs.put(fieldRef); } - const field = this.xref.fetchIfRef(fieldRef); + const field = await this.xref.fetchIfRefAsync(fieldRef); if (!(field instanceof Dict)) { continue; } - if (isName(field.get("FT"), "Sig")) { - const sigDict = this.xref.fetchIfRef(field.get("V")); + if (isName(await field.getAsync("FT"), "Sig")) { + const sigDict = await field.getAsync("V"); if (sigDict instanceof Dict) { - const parsed = this.#parseSignatureDict(field, sigDict, fieldRef); + const parsed = await this.#parseSignatureDict( + field, + sigDict, + fieldRef + ); if (parsed) { out.push(parsed); } @@ -2023,7 +2027,11 @@ class PDFDocument { if (field.has("Kids")) { // A terminal field can have Widget annotations as children, so its // own signature must be collected before walking the field tree. - this.#collectSignatureFields(field.get("Kids"), out, visitedRefs); + await this.#collectSignatureFields( + await field.getAsync("Kids"), + out, + visitedRefs + ); } } } @@ -2070,8 +2078,8 @@ class PDFDocument { return true; } - #parseSignatureDict(field, sigDict, fieldRef) { - const byteRange = sigDict.get("ByteRange"); + async #parseSignatureDict(field, sigDict, fieldRef) { + const byteRange = await sigDict.getAsync("ByteRange"); if ( !Array.isArray(byteRange) || byteRange.length !== 4 || @@ -2079,15 +2087,17 @@ class PDFDocument { ) { return null; } - const contents = sigDict.get("Contents"); + const contents = await sigDict.getAsync("Contents"); if (typeof contents !== "string" || contents.length === 0) { return null; } - const filterName = sigDict.get("Filter"); - const filter = filterName instanceof Name ? filterName.name : null; - const subFilterName = sigDict.get("SubFilter"); - const subFilter = subFilterName instanceof Name ? subFilterName.name : null; + const [filterName, subFilterName] = await Promise.all([ + sigDict.getAsync("Filter"), + sigDict.getAsync("SubFilter"), + ]); + const filter = filterName instanceof Name ? filterName.name : null, + subFilter = subFilterName instanceof Name ? subFilterName.name : null; let signatureType = null; if (subFilter === "adbe.pkcs7.detached") { @@ -2118,22 +2128,20 @@ class PDFDocument { ) { return null; } - const pkcs7 = stringToBytes(contents); - - const t = field.get("T"); - const fieldName = typeof t === "string" ? stringToPDFString(t) : ""; - const name = sigDict.get("Name"); - const reason = sigDict.get("Reason"); - const location = sigDict.get("Location"); - const contactInfo = sigDict.get("ContactInfo"); - const m = sigDict.get("M"); + const [t, name, reason, location, contactInfo, m] = await Promise.all([ + field.getAsync("T"), + sigDict.getAsync("Name"), + sigDict.getAsync("Reason"), + sigDict.getAsync("Location"), + sigDict.getAsync("ContactInfo"), + sigDict.getAsync("M"), + ]); const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline"; - const id = `${refKey}:${a}-${b}-${c}-${d}`; return { - id, - fieldName, + id: `${refKey}:${a}-${b}-${c}-${d}`, + fieldName: typeof t === "string" ? stringToPDFString(t) : "", signerName: typeof name === "string" ? stringToPDFString(name) : null, reason: typeof reason === "string" ? stringToPDFString(reason) : null, location: @@ -2145,7 +2153,7 @@ class PDFDocument { subFilter, signatureType, byteRange, - pkcs7, + pkcs7: stringToBytes(contents), revisionIndex: 0, parentId: null, }; @@ -2165,7 +2173,7 @@ class PDFDocument { const fields = annotationGlobals.acroForm.get("Fields"); const collected = []; - this.#collectSignatureFields(fields, collected, new RefSet()); + await this.#collectSignatureFields(fields, collected, new RefSet()); await Promise.all( collected.map(async signature => { diff --git a/test/unit/document_spec.js b/test/unit/document_spec.js index a4718fe3f..f56a03928 100644 --- a/test/unit/document_spec.js +++ b/test/unit/document_spec.js @@ -322,6 +322,10 @@ describe("document", function () { { ref: sigRef, data: sigDict }, { ref: fieldRef, data: fieldDict }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + fieldDict.assignXref(xref); + acroForm.set("Fields", [fieldRef]); const pdfDocument = getDocument(acroForm, xref); @@ -362,6 +366,10 @@ describe("document", function () { { ref: sigRef, data: sigDict }, { ref: fieldRef, data: fieldDict }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + fieldDict.assignXref(xref); + acroForm.set("Fields", [fieldRef]); const documentStream = new StringStream( @@ -386,6 +394,10 @@ describe("document", function () { { ref: sigRef, data: sigDict }, { ref: fieldRef, data: fieldDict }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + fieldDict.assignXref(xref); + acroForm.set("Fields", [fieldRef]); const documentStream = new StringStream( @@ -418,6 +430,11 @@ describe("document", function () { { ref: sigFieldRef, data: sigField }, { ref: containerRef, data: container }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + sigField.assignXref(xref); + container.assignXref(xref); + acroForm.set("Fields", [containerRef]); const pdfDocument = getDocument(acroForm, xref); @@ -450,6 +467,11 @@ describe("document", function () { { ref: sigFieldRef, data: sigField }, { ref: widgetRef, data: widget }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + sigField.assignXref(xref); + widget.assignXref(xref); + acroForm.set("Fields", [sigFieldRef]); const pdfDocument = getDocument(acroForm, xref); @@ -472,6 +494,10 @@ describe("document", function () { { ref: sigRef, data: sigDict }, { ref: fieldRef, data: fieldDict }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + fieldDict.assignXref(xref); + acroForm.set("Fields", [fieldRef]); const pdfDocument = getDocument(acroForm, xref); @@ -498,18 +524,21 @@ describe("document", function () { name: "Inner", }); + const outerField = makeSigField({ T: "outer", sigRef: outerSigRef }); + const innerField = makeSigField({ T: "inner", sigRef: innerSigRef }); + const xref = new XRefMock([ { ref: outerSigRef, data: outerSig }, - { - ref: outerFieldRef, - data: makeSigField({ T: "outer", sigRef: outerSigRef }), - }, + { ref: outerFieldRef, data: outerField }, { ref: innerSigRef, data: innerSig }, - { - ref: innerFieldRef, - data: makeSigField({ T: "inner", sigRef: innerSigRef }), - }, + { ref: innerFieldRef, data: innerField }, ]); + acroForm.assignXref(xref); + outerSig.assignXref(xref); + innerSig.assignXref(xref); + outerField.assignXref(xref); + innerField.assignXref(xref); + acroForm.set("Fields", [outerFieldRef, innerFieldRef]); const pdfDocument = getDocument(acroForm, xref); @@ -535,14 +564,18 @@ describe("document", function () { byteRange: [0, 10, 20, 30], subFilter, }); + const sigField = makeSigField({ T: "sig", sigRef }); + const xref = new XRefMock([ { ref: sigRef, data: sigDict }, - { - ref: fieldRef, - data: makeSigField({ T: "sig", sigRef }), - }, + { ref: fieldRef, data: sigField }, ]); + acroForm.assignXref(xref); + sigDict.assignXref(xref); + sigField.assignXref(xref); + acroForm.set("Fields", [fieldRef]); + const pdfDocument = getDocument(acroForm, xref); const [sig] = await pdfDocument.signatures; return sig.signatureType; From 1ce8e8a320a9bb12015613a29e8f3c9beec640eb Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Tue, 28 Jul 2026 15:58:11 +0200 Subject: [PATCH 2/3] Move the `ByteRange` validation earlier when parsing signatures Given that these checks are synchronous, we can avoid a little bit of unnecessary data-fetching if the `ByteRange` is invalid. --- src/core/document.js | 45 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/core/document.js b/src/core/document.js index b9ad8b899..9d6348253 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -2087,6 +2087,28 @@ class PDFDocument { ) { return null; } + // Slice the two ByteRange byte spans out of the underlying PDF stream. + // ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d]; + // the gap covers the /Contents hex blob itself. + const [a, b, c, d] = byteRange; + // `/ByteRange` offsets are absolute, so compare against `stream.end` + // (raw buffer end), not `stream.length` (post-`moveStart` payload). + const fileLength = this.stream.end || 0; + // Reject signatures whose /ByteRange is structurally implausible: it + // must start at the file head, define a non-empty first span, leave + // room for the /Contents blob between the two spans, and fit within + // the file. Without this a crafted PDF can claim to cover the whole + // document while only signing a small prologue. + if ( + a !== 0 || + b <= 0 || + a + b > c || + c + d > fileLength || + fileLength === 0 + ) { + return null; + } + const contents = await sigDict.getAsync("Contents"); if (typeof contents !== "string" || contents.length === 0) { return null; @@ -2106,29 +2128,6 @@ class PDFDocument { signatureType = 1; } - // Slice the two ByteRange byte spans out of the underlying PDF stream. - // ByteRange = [a, b, c, d] means signed bytes are [a..a+b] and [c..c+d]; - // the gap covers the /Contents hex blob itself. - const [a, b, c, d] = byteRange; - const stream = this.stream; - // `/ByteRange` offsets are absolute, so compare against `stream.end` - // (raw buffer end), not `stream.length` (post-`moveStart` payload). - const fileLength = stream.end || 0; - // Reject signatures whose /ByteRange is structurally implausible: it - // must start at the file head, define a non-empty first span, leave - // room for the /Contents blob between the two spans, and fit within - // the file. Without this a crafted PDF can claim to cover the whole - // document while only signing a small prologue. - if ( - a !== 0 || - b <= 0 || - a + b > c || - c + d > fileLength || - fileLength === 0 - ) { - return null; - } - const [t, name, reason, location, contactInfo, m] = await Promise.all([ field.getAsync("T"), sigDict.getAsync("Name"), From c9eaf13ae49cad7c22bf3cde4592cd3ca6ab4edc Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Tue, 28 Jul 2026 16:04:05 +0200 Subject: [PATCH 3/3] Lookup more data in parallel in the `PDFDocument.prototype.#parseSignatureDict` method --- src/core/document.js | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/core/document.js b/src/core/document.js index 9d6348253..778308952 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -2114,10 +2114,26 @@ class PDFDocument { return null; } - const [filterName, subFilterName] = await Promise.all([ + const [ + filterName, + subFilterName, + t, + name, + reason, + location, + contactInfo, + m, + ] = await Promise.all([ sigDict.getAsync("Filter"), sigDict.getAsync("SubFilter"), + field.getAsync("T"), + sigDict.getAsync("Name"), + sigDict.getAsync("Reason"), + sigDict.getAsync("Location"), + sigDict.getAsync("ContactInfo"), + sigDict.getAsync("M"), ]); + const filter = filterName instanceof Name ? filterName.name : null, subFilter = subFilterName instanceof Name ? subFilterName.name : null; @@ -2127,15 +2143,6 @@ class PDFDocument { } else if (subFilter === "adbe.pkcs7.sha1") { signatureType = 1; } - - const [t, name, reason, location, contactInfo, m] = await Promise.all([ - field.getAsync("T"), - sigDict.getAsync("Name"), - sigDict.getAsync("Reason"), - sigDict.getAsync("Location"), - sigDict.getAsync("ContactInfo"), - sigDict.getAsync("M"), - ]); const refKey = fieldRef instanceof Ref ? fieldRef.toString() : "inline"; return {