Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/annotations/annotations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PdfArray } from "#src/objects/pdf-array";
import { PdfDict } from "#src/objects/pdf-dict";
import { PdfName } from "#src/objects/pdf-name";
import { PdfNumber } from "#src/objects/pdf-number";
import { PdfRef } from "#src/objects/pdf-ref";
import { describe, expect, it } from "vitest";

import type { PDFLinkAnnotation } from "./link";
Expand Down Expand Up @@ -629,6 +630,54 @@ describe("PDFAnnotations", () => {
expect(page.getAnnotations()).toHaveLength(0);
});

it("drops dangling /Annots refs during flatten", async () => {
// Regression: /Annots entries whose target object doesn't exist were
// skipped (`continue`) instead of removed. The dangling ref survived
// into the full save, where renumbering left it pointing above /Size —
// and a later incremental update (signing) reused that number,
// invalidating signatures in Adobe.
const pdf = PDF.create();
const page = pdf.addPage();

page.addHighlightAnnotation({
rect: { x: 100, y: 700, width: 200, height: 14 },
color: rgb(1, 1, 0),
});

// Inject a ref whose target is never defined.
const annots = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref));
expect(annots).toBeInstanceOf(PdfArray);
(annots as PdfArray).push(PdfRef.of(999, 0));

const count = page.flattenAnnotations();
expect(count).toBe(1);

// The dangling ref must not survive in /Annots.
const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref));

if (after instanceof PdfArray) {
for (const item of after) {
expect(item).not.toBe(PdfRef.of(999, 0));
}
}
});

it("drops dangling /Annots refs even when nothing is flattenable", async () => {
const pdf = PDF.create();
const page = pdf.addPage();

page.dict.set("Annots", new PdfArray([PdfRef.of(999, 0)]));

const count = page.flattenAnnotations();
expect(count).toBe(0);

const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref));

if (after instanceof PdfArray) {
expect(after.length).toBe(0);
}
});

it("preserves link annotations during flatten", async () => {
const pdf = PDF.create();
const page = pdf.addPage();
Expand Down
7 changes: 7 additions & 0 deletions src/annotations/flattener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ export class AnnotationFlattener {
}

if (!annotDict) {
// Dangling ref (target doesn't exist) or resolves to a non-dict.
// Per PDF 1.7 §7.3.10 such refs are null, remove them from /Annots
// rather than skipping.
if (annotRef) {
refsToRemove.add(`${annotRef.objectNumber} ${annotRef.generation}`);
}

continue;
}

Expand Down
30 changes: 30 additions & 0 deletions src/document/forms/acro-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PdfDict } from "#src/objects/pdf-dict";
import { PdfName } from "#src/objects/pdf-name";
import { PdfNumber } from "#src/objects/pdf-number";
import type { PdfObject } from "#src/objects/pdf-object";
import { PdfRef } from "#src/objects/pdf-ref";
import { loadFixture, toAsciiString } from "#src/test-utils";
import { describe, expect, it } from "vitest";

Expand Down Expand Up @@ -873,6 +874,35 @@ describe("Form Writing", () => {
expect(countPageWidgets(pdf2)).toBe(0);
});

it("drops dangling /Annots refs when removing flattened widgets", async () => {
// Regression: removeAnnotations rebuilt /Annots keeping every ref not
// in the removal set — refs whose targets don't exist were never in
// that set, so they survived flatten and later broke signature
// validation after full-save renumbering + incremental signing.
const bytes = await loadFixture("forms", "sample_form.pdf");
const pdf = await PDF.load(bytes);
const form = pdf.getForm()?.acroForm();

const page = pdf.getPages()[0];
const annots = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref));
expect(annots?.type).toBe("array");

// Inject a ref whose target is never defined anywhere in the file.
(annots as PdfArray).push(PdfRef.of(9999, 0));

form!.flatten();

const after = page.dict.get("Annots", ref => pdf.context.registry.resolve(ref));

if (after && after.type === "array") {
for (const item of after) {
if (item instanceof PdfRef) {
expect(item.objectNumber).not.toBe(9999);
}
}
}
});

it("handles checkbox fields correctly", async () => {
const bytes = await loadFixture("forms", "sample_form.pdf");
const pdf = await PDF.load(bytes);
Expand Down
12 changes: 10 additions & 2 deletions src/document/forms/form-flattener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,9 +617,17 @@ export class FormFlattener {
if (item instanceof PdfRef) {
const key = `${item.objectNumber} ${item.generation}`;

if (!toRemove.has(key)) {
remaining.push(item);
if (toRemove.has(key)) {
continue;
}

// Drop dangling refs (targets that don't exist). Per PDF 1.7
// §7.3.10 they are null anyway.
if (this.registry.resolve(item) === null) {
continue;
}

remaining.push(item);
}
}

Expand Down
37 changes: 37 additions & 0 deletions src/document/object-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,43 @@ describe("ObjectRegistry", () => {
});
});

describe("dangling reference defense", () => {
it("bumps nextObjectNumber when resolving a ref beyond the xref range", () => {
// Regression: malformed files can reference object numbers above their
// own /Size (dangling refs). If the allocation cursor only considers
// DEFINED objects, a later incremental update (e.g. signing) allocates
// numbers that signed content already references — redefining them and
// breaking signature validation in Adobe.
const xref = new Map<number, XRefEntry>([
[1, { type: "uncompressed", offset: 100, generation: 0 }],
[5, { type: "uncompressed", offset: 200, generation: 0 }],
]);

const registry = new ObjectRegistry(xref);
expect(registry.nextObjectNumber).toBe(6);

// Resolving a dangling ref (no resolver → null) must still bump the cursor.
expect(registry.resolve(PdfRef.of(295, 0))).toBeNull();
expect(registry.nextObjectNumber).toBe(296);

const ref = registry.register(new PdfDict());
expect(ref.objectNumber).toBe(296);
});

it("does not bump nextObjectNumber for in-range refs", () => {
const xref = new Map<number, XRefEntry>([
[1, { type: "uncompressed", offset: 100, generation: 0 }],
[5, { type: "uncompressed", offset: 200, generation: 0 }],
]);

const registry = new ObjectRegistry(xref);

registry.resolve(PdfRef.of(3, 0));

expect(registry.nextObjectNumber).toBe(6);
});
});

describe("commitNewObjects", () => {
it("moves new objects to loaded", () => {
const registry = new ObjectRegistry();
Expand Down
8 changes: 8 additions & 0 deletions src/document/object-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ export class ObjectRegistry {
* @returns The object, or null if not found
*/
resolve(ref: PdfRef): PdfObject | null {
// Malformed files can reference object numbers at or above
// the allocation cursor (dangling refs beyond the xref's /Size). Bump
// the cursor so newly allocated numbers never collide with numbers the
// existing content already references.
if (ref.objectNumber >= this._nextObjNum) {
this._nextObjNum = ref.objectNumber + 1;
}

// Check registry first
const existing = this.getObject(ref);

Expand Down
101 changes: 101 additions & 0 deletions src/writer/pdf-writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,39 @@ describe("writeComplete", () => {
}
}

/**
* Build a minimal raw PDF whose page /Annots references object numbers
* (295, 299) that are not defined anywhere in the file — dangling refs
* above the file's own /Size, as seen in malformed uploads and files
* pre-processed by broken third-party flatteners.
*/
function buildPdfWithDanglingAnnots(): Uint8Array {
const objects = [
"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [295 0 R 299 0 R] >>\nendobj\n",
];

let body = "%PDF-1.7\n%\xe2\xe3\xcf\xd3\n";
const offsets: number[] = [];

for (const obj of objects) {
offsets.push(body.length);
body += obj;
}

const xrefOffset = body.length;
let xref = "xref\n0 4\n0000000000 65535 f \n";

for (const off of offsets) {
xref += `${String(off).padStart(10, "0")} 00000 n \n`;
}

const trailer = `trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;

return Uint8Array.from(Buffer.from(body + xref + trailer, "latin1"));
}

it("produces a single contiguous xref subsection (0..N)", async () => {
// Build a PDF that registers extra objects then orphans some, so the
// pre-fix code would have produced gap-laden subsections.
Expand Down Expand Up @@ -613,6 +646,74 @@ describe("writeComplete", () => {
expect(pdf2.getForm()?.getFields().length).toBe(fieldsBefore);
});

it("replaces dangling refs (targets never registered) with null", () => {
// Regression: refs whose targets are not in the registry used to pass
// through renumbering with their ORIGINAL object numbers. After dense
// compaction those numbers land above the new /Size, and a later
// incremental update (signing, DSS) re-allocates them — redefining
// objects that signed content still references. Adobe then reports
// "Document has been altered or corrupted since it was signed".
// Per PDF 1.7 §7.3.10 a ref to a nonexistent object is null.
const registry = new ObjectRegistry();

// Orphan forces renumbering to actually move numbers around.
registry.register(PdfDict.of({ Type: PdfName.of("Orphan") }));

const page = PdfDict.of({
Type: PdfName.of("Page"),
// 295 and 299 are never registered — dangling refs.
Annots: new PdfArray([PdfRef.of(295, 0), PdfRef.of(299, 0)]),
});
const pageRef = registry.register(page);

const catalog = PdfDict.of({ Type: PdfName.Catalog, Page: pageRef });
const catalogRef = registry.register(catalog);

const result = writeComplete(registry, { root: catalogRef });
const text = Buffer.from(result.bytes).toString("latin1");

// The dangling refs must not survive with their original numbers.
expect(text).not.toContain("295 0 R");
expect(text).not.toContain("299 0 R");

// They must be written as null.
expect(text).toMatch(/\/Annots\s*\[\s*null\s+null\s*\]/);

assertNoDanglingRefs(result.bytes);
});

it("full save of a loaded PDF with dangling /Annots refs emits no refs at or above /Size", async () => {
// End-to-end variant through the real parse path: a file whose page
// /Annots references object numbers that are not defined anywhere
// (above its own /Size), as produced by broken third-party tools.
const raw = buildPdfWithDanglingAnnots();
const pdf = await PDF.load(raw);

// Force a dirty mark so save() actually rewrites.
pdf.getCatalog().set("LangX", PdfString.fromString("x"));

const saved = await pdf.save();
const text = Buffer.from(saved).toString("latin1");

// Original dangling numbers must not appear as refs in the output.
expect(text).not.toContain("295 0 R");
expect(text).not.toContain("299 0 R");
assertNoDanglingRefs(saved);

// No reference anywhere in the output may be >= /Size.
const sizeMatch = text.match(/\/Size\s+(\d+)/);
expect(sizeMatch).not.toBeNull();
const size = Number(sizeMatch![1]);

for (const [, numStr] of text.matchAll(/(\d+) \d+ R\b/g)) {
expect(Number(numStr)).toBeLessThan(size);
}

// The file must still reload cleanly.
const pdf2 = await PDF.load(saved);
expect(pdf2.getPages().length).toBe(1);
});

it("preserves generation numbers on renumbered refs", () => {
// A defensive check: even though most PDFs use generation 0
// exclusively, the spec allows non-zero generations and the
Expand Down
9 changes: 8 additions & 1 deletion src/writer/pdf-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ByteWriter } from "#src/io/byte-writer";
import { PdfArray } from "#src/objects/pdf-array";
import { PdfDict } from "#src/objects/pdf-dict";
import { PdfName } from "#src/objects/pdf-name";
import { PdfNull } from "#src/objects/pdf-null";
import type { PdfObject } from "#src/objects/pdf-object";
import { PdfRef } from "#src/objects/pdf-ref";
import { PdfStream } from "#src/objects/pdf-stream";
Expand Down Expand Up @@ -302,7 +303,13 @@ function renumberRef(ref: PdfRef, renumberMap: Map<number, number>): PdfRef {
*/
function renumberRefs(obj: PdfObject, renumberMap: Map<number, number>): PdfObject {
if (obj instanceof PdfRef) {
return renumberRef(obj, renumberMap);
const newNum = renumberMap.get(obj.objectNumber);

if (newNum === undefined) {
return PdfNull.instance;
}

return PdfRef.of(newNum, obj.generation);
}

if (obj instanceof PdfStream) {
Expand Down
Loading