mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-07-30 19:07:22 +02:00
Avoid cycles when walking some trees
This commit is contained in:
parent
1609bd87c5
commit
ac51b29777
@ -1598,6 +1598,9 @@ class Catalog {
|
|||||||
const xref = this.xref;
|
const xref = this.xref;
|
||||||
let total = 0,
|
let total = 0,
|
||||||
ref = pageRef;
|
ref = pageRef;
|
||||||
|
// Prevent circular references in the /Pages tree.
|
||||||
|
const visited = new RefSet();
|
||||||
|
visited.put(pageRef);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const node = await xref.fetchAsync(ref);
|
const node = await xref.fetchAsync(ref);
|
||||||
@ -1617,6 +1620,12 @@ class Catalog {
|
|||||||
throw new FormatError("Node must be a dictionary.");
|
throw new FormatError("Node must be a dictionary.");
|
||||||
}
|
}
|
||||||
const parentRef = node.getRaw("Parent");
|
const parentRef = node.getRaw("Parent");
|
||||||
|
if (parentRef instanceof Ref) {
|
||||||
|
if (visited.has(parentRef)) {
|
||||||
|
throw new FormatError("Pages tree contains circular reference.");
|
||||||
|
}
|
||||||
|
visited.put(parentRef);
|
||||||
|
}
|
||||||
|
|
||||||
const parent = await node.getAsync("Parent");
|
const parent = await node.getAsync("Parent");
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
@ -1729,9 +1738,19 @@ class Catalog {
|
|||||||
// reached (e.g. integer MCIDs or MCR/OBJR dicts without further K).
|
// reached (e.g. integer MCIDs or MCR/OBJR dicts without further K).
|
||||||
if (!pageRef) {
|
if (!pageRef) {
|
||||||
const queue = [seDict];
|
const queue = [seDict];
|
||||||
|
// Prevent circular references in the structure tree.
|
||||||
|
const visited = new RefSet();
|
||||||
|
visited.put(seRef);
|
||||||
while (queue.length > 0 && !pageRef) {
|
while (queue.length > 0 && !pageRef) {
|
||||||
const node = queue.shift();
|
const node = queue.shift();
|
||||||
const kids = node.get("K");
|
let kids = node.getRaw("K");
|
||||||
|
if (kids instanceof Ref) {
|
||||||
|
if (visited.has(kids)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.put(kids);
|
||||||
|
kids = xref.fetch(kids);
|
||||||
|
}
|
||||||
let kidsArr;
|
let kidsArr;
|
||||||
if (Array.isArray(kids)) {
|
if (Array.isArray(kids)) {
|
||||||
kidsArr = kids;
|
kidsArr = kids;
|
||||||
@ -1741,6 +1760,12 @@ class Catalog {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const kid of kidsArr) {
|
for (const kid of kidsArr) {
|
||||||
|
if (kid instanceof Ref) {
|
||||||
|
if (visited.has(kid)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
visited.put(kid);
|
||||||
|
}
|
||||||
const kidObj = xref.fetchIfRef(kid);
|
const kidObj = xref.fetchIfRef(kid);
|
||||||
if (!(kidObj instanceof Dict)) {
|
if (!(kidObj instanceof Dict)) {
|
||||||
continue; // integer MCID – leaf node, no Pg here
|
continue; // integer MCID – leaf node, no Pg here
|
||||||
|
|||||||
@ -1880,12 +1880,15 @@ class PDFDocument {
|
|||||||
name = name === "" ? partName : `${name}.${partName}`;
|
name = name === "" ? partName : `${name}.${partName}`;
|
||||||
} else {
|
} else {
|
||||||
let obj = field;
|
let obj = field;
|
||||||
|
// The `Parent` chain can be cyclic, hence the local `RefSet`.
|
||||||
|
const walkedRefs = new RefSet();
|
||||||
while (true) {
|
while (true) {
|
||||||
obj = obj.getRaw("Parent") || parentRef;
|
obj = obj.getRaw("Parent") || parentRef;
|
||||||
if (obj instanceof Ref) {
|
if (obj instanceof Ref) {
|
||||||
if (visitedRefs.has(obj)) {
|
if (visitedRefs.has(obj) || walkedRefs.has(obj)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
walkedRefs.put(obj);
|
||||||
obj = await xref.fetchAsync(obj);
|
obj = await xref.fetchAsync(obj);
|
||||||
}
|
}
|
||||||
if (!(obj instanceof Dict)) {
|
if (!(obj instanceof Dict)) {
|
||||||
|
|||||||
189
test/unit/catalog_spec.js
Normal file
189
test/unit/catalog_spec.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
/* Copyright 2026 Mozilla Foundation
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Dict, Name, Ref } from "../../src/core/primitives.js";
|
||||||
|
import { BoundedXRefMock } from "./test_utils.js";
|
||||||
|
import { Catalog } from "../../src/core/catalog.js";
|
||||||
|
import { FormatError } from "../../src/shared/util.js";
|
||||||
|
|
||||||
|
describe("Catalog", function () {
|
||||||
|
class CatalogXRefMock extends BoundedXRefMock {
|
||||||
|
#catalogObj = null;
|
||||||
|
|
||||||
|
constructor(entries, catalogObj) {
|
||||||
|
super(entries);
|
||||||
|
this.#catalogObj = catalogObj;
|
||||||
|
|
||||||
|
for (const { data } of entries) {
|
||||||
|
data.assignXref(this);
|
||||||
|
}
|
||||||
|
catalogObj?.assignXref(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCatalogObj() {
|
||||||
|
return this.#catalogObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCatalog(entries) {
|
||||||
|
const topPagesDict = new Dict();
|
||||||
|
topPagesDict.set("Type", Name.get("Pages"));
|
||||||
|
topPagesDict.set("Kids", []);
|
||||||
|
topPagesDict.set("Count", 0);
|
||||||
|
|
||||||
|
const catDict = new Dict();
|
||||||
|
catDict.set("Type", Name.get("Catalog"));
|
||||||
|
catDict.set("Pages", topPagesDict);
|
||||||
|
|
||||||
|
const xref = new CatalogXRefMock(entries, catDict);
|
||||||
|
return new Catalog(/* pdfManager = */ null, xref);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getPageIndex", function () {
|
||||||
|
it("should reject a circular `Parent` chain", async function () {
|
||||||
|
// The page and its parent reference each other, and both `Kids` arrays
|
||||||
|
// are consistent with that, hence walking up the tree never terminates.
|
||||||
|
const pageRef = Ref.get(4, 0);
|
||||||
|
const pagesRef = Ref.get(5, 0);
|
||||||
|
|
||||||
|
const pageDict = new Dict();
|
||||||
|
pageDict.set("Type", Name.get("Page"));
|
||||||
|
pageDict.set("Parent", pagesRef);
|
||||||
|
pageDict.set("Kids", [pagesRef]);
|
||||||
|
|
||||||
|
const pagesDict = new Dict();
|
||||||
|
pagesDict.set("Type", Name.get("Pages"));
|
||||||
|
pagesDict.set("Parent", pageRef);
|
||||||
|
pagesDict.set("Kids", [pageRef]);
|
||||||
|
pagesDict.set("Count", 1);
|
||||||
|
|
||||||
|
const catalog = makeCatalog([
|
||||||
|
{ ref: pageRef, data: pageDict },
|
||||||
|
{ ref: pagesRef, data: pagesDict },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expectAsync(catalog.getPageIndex(pageRef)).toBeRejectedWithError(
|
||||||
|
FormatError,
|
||||||
|
"Pages tree contains circular reference."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseDestDictionary", function () {
|
||||||
|
function makeStructElements(entries) {
|
||||||
|
const xref = new BoundedXRefMock(entries);
|
||||||
|
for (const { data } of entries) {
|
||||||
|
data.assignXref(xref);
|
||||||
|
}
|
||||||
|
return xref;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should handle a circular `K` chain in the `SE` entry", function () {
|
||||||
|
const seRef = Ref.get(5, 0);
|
||||||
|
const kidRef = Ref.get(6, 0);
|
||||||
|
|
||||||
|
// Neither element provides a `Pg` entry, hence the descendants of the
|
||||||
|
// structure element are all visited before giving up.
|
||||||
|
const seDict = new Dict();
|
||||||
|
seDict.set("S", Name.get("Sect"));
|
||||||
|
seDict.set("K", [kidRef]);
|
||||||
|
|
||||||
|
const kidDict = new Dict();
|
||||||
|
kidDict.set("S", Name.get("Sect"));
|
||||||
|
kidDict.set("K", [seRef]);
|
||||||
|
|
||||||
|
const xref = makeStructElements([
|
||||||
|
{ ref: seRef, data: seDict },
|
||||||
|
{ ref: kidRef, data: kidDict },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const destDict = new Dict(xref);
|
||||||
|
destDict.set("SE", seRef);
|
||||||
|
|
||||||
|
const resultObj = {};
|
||||||
|
Catalog.parseDestDictionary({ destDict, resultObj });
|
||||||
|
expect(resultObj.dest).toBeUndefined();
|
||||||
|
// `parseDestDictionary` swallows errors from the `SE` parsing, hence the
|
||||||
|
// number of fetches is what actually proves that the cycle was detected.
|
||||||
|
expect(xref.fetchCount).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle a circular single-reference `K` chain", function () {
|
||||||
|
const seRef = Ref.get(5, 0);
|
||||||
|
const kidRef = Ref.get(6, 0);
|
||||||
|
|
||||||
|
const seDict = new Dict();
|
||||||
|
seDict.set("S", Name.get("Sect"));
|
||||||
|
seDict.set("K", kidRef);
|
||||||
|
|
||||||
|
const kidDict = new Dict();
|
||||||
|
kidDict.set("S", Name.get("Sect"));
|
||||||
|
kidDict.set("K", seRef);
|
||||||
|
|
||||||
|
const xref = makeStructElements([
|
||||||
|
{ ref: seRef, data: seDict },
|
||||||
|
{ ref: kidRef, data: kidDict },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const destDict = new Dict(xref);
|
||||||
|
destDict.set("SE", seRef);
|
||||||
|
|
||||||
|
const resultObj = {};
|
||||||
|
Catalog.parseDestDictionary({ destDict, resultObj });
|
||||||
|
expect(resultObj.dest).toBeUndefined();
|
||||||
|
expect(xref.fetchCount).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should find the page of a nested `SE` entry", function () {
|
||||||
|
const seRef = Ref.get(5, 0);
|
||||||
|
const kidRef = Ref.get(6, 0);
|
||||||
|
const grandKidRef = Ref.get(7, 0);
|
||||||
|
const pageRef = Ref.get(8, 0);
|
||||||
|
|
||||||
|
const seDict = new Dict();
|
||||||
|
seDict.set("S", Name.get("Sect"));
|
||||||
|
seDict.set("K", [kidRef]);
|
||||||
|
|
||||||
|
// Only the grand-kid provides a `Pg` entry, hence the descendants must
|
||||||
|
// actually be enqueued and visited to find it.
|
||||||
|
const kidDict = new Dict();
|
||||||
|
kidDict.set("S", Name.get("Sect"));
|
||||||
|
kidDict.set("K", [grandKidRef]);
|
||||||
|
|
||||||
|
const grandKidDict = new Dict();
|
||||||
|
grandKidDict.set("S", Name.get("P"));
|
||||||
|
grandKidDict.set("Pg", pageRef);
|
||||||
|
|
||||||
|
const xref = makeStructElements([
|
||||||
|
{ ref: seRef, data: seDict },
|
||||||
|
{ ref: kidRef, data: kidDict },
|
||||||
|
{ ref: grandKidRef, data: grandKidDict },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const destDict = new Dict(xref);
|
||||||
|
destDict.set("SE", seRef);
|
||||||
|
|
||||||
|
const resultObj = {};
|
||||||
|
Catalog.parseDestDictionary({ destDict, resultObj });
|
||||||
|
expect(resultObj.dest).toEqual([
|
||||||
|
pageRef,
|
||||||
|
{ name: "XYZ" },
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -11,6 +11,7 @@
|
|||||||
"autolinker_spec.js",
|
"autolinker_spec.js",
|
||||||
"bidi_spec.js",
|
"bidi_spec.js",
|
||||||
"canvas_factory_spec.js",
|
"canvas_factory_spec.js",
|
||||||
|
"catalog_spec.js",
|
||||||
"cff_parser_spec.js",
|
"cff_parser_spec.js",
|
||||||
"cmap_spec.js",
|
"cmap_spec.js",
|
||||||
"colorspace_spec.js",
|
"colorspace_spec.js",
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createIdFactory, XRefMock } from "./test_utils.js";
|
import { BoundedXRefMock, createIdFactory, XRefMock } from "./test_utils.js";
|
||||||
import { Dict, Name, Ref } from "../../src/core/primitives.js";
|
import { Dict, Name, Ref } from "../../src/core/primitives.js";
|
||||||
import { PDFDocument } from "../../src/core/document.js";
|
import { PDFDocument } from "../../src/core/document.js";
|
||||||
import { StringStream } from "../../src/core/stream.js";
|
import { StringStream } from "../../src/core/stream.js";
|
||||||
@ -630,6 +630,41 @@ describe("document", function () {
|
|||||||
expect(fields.parent).toEqual(["358R"]);
|
expect(fields.parent).toEqual(["358R"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should get field objects with a circular `Parent` chain", async function () {
|
||||||
|
// A field without a `T` entry inherits its name from the `Parent` chain,
|
||||||
|
// which may be circular in corrupt/malicious documents.
|
||||||
|
const widgetRef = Ref.get(1, 0);
|
||||||
|
const parentRef = Ref.get(2, 0);
|
||||||
|
const grandParentRef = Ref.get(3, 0);
|
||||||
|
|
||||||
|
const widgetDict = new Dict();
|
||||||
|
widgetDict.set("Type", Name.get("Annot"));
|
||||||
|
widgetDict.set("Subtype", Name.get("Widget"));
|
||||||
|
widgetDict.set("FT", Name.get("Btn"));
|
||||||
|
widgetDict.set("Parent", parentRef);
|
||||||
|
|
||||||
|
// Note that the cycle doesn't include the field itself, and that neither
|
||||||
|
// ancestor provides a `T` entry.
|
||||||
|
const parentDict = new Dict();
|
||||||
|
parentDict.set("Parent", grandParentRef);
|
||||||
|
const grandParentDict = new Dict();
|
||||||
|
grandParentDict.set("Parent", parentRef);
|
||||||
|
|
||||||
|
const xref = new BoundedXRefMock([
|
||||||
|
{ ref: widgetRef, data: widgetDict },
|
||||||
|
{ ref: parentRef, data: parentDict },
|
||||||
|
{ ref: grandParentRef, data: grandParentDict },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const acroForm = new Dict();
|
||||||
|
acroForm.set("Fields", [widgetRef]);
|
||||||
|
const pdfDocument = getDocument(acroForm, xref);
|
||||||
|
|
||||||
|
const { allFields } = await pdfDocument.fieldObjects;
|
||||||
|
expect(Object.keys(allFields)).toEqual([""]);
|
||||||
|
expect(allFields[""].map(obj => obj.id)).toEqual(["1R"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("should check if fields have any actions", async function () {
|
it("should check if fields have any actions", async function () {
|
||||||
const acroForm = new Dict();
|
const acroForm = new Dict();
|
||||||
|
|
||||||
|
|||||||
@ -57,6 +57,7 @@ async function initializePDFJS(callback) {
|
|||||||
"pdfjs-test/unit/autolinker_spec.js",
|
"pdfjs-test/unit/autolinker_spec.js",
|
||||||
"pdfjs-test/unit/bidi_spec.js",
|
"pdfjs-test/unit/bidi_spec.js",
|
||||||
"pdfjs-test/unit/canvas_factory_spec.js",
|
"pdfjs-test/unit/canvas_factory_spec.js",
|
||||||
|
"pdfjs-test/unit/catalog_spec.js",
|
||||||
"pdfjs-test/unit/cff_parser_spec.js",
|
"pdfjs-test/unit/cff_parser_spec.js",
|
||||||
"pdfjs-test/unit/cmap_spec.js",
|
"pdfjs-test/unit/cmap_spec.js",
|
||||||
"pdfjs-test/unit/colorspace_spec.js",
|
"pdfjs-test/unit/colorspace_spec.js",
|
||||||
|
|||||||
@ -177,6 +177,28 @@ class XRefMock {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `XRefMock` variant that limits the number of fetches, such that tests
|
||||||
|
* exercising cycle detection fail rather than hang when a guard is missing.
|
||||||
|
*/
|
||||||
|
class BoundedXRefMock extends XRefMock {
|
||||||
|
#limit;
|
||||||
|
|
||||||
|
fetchCount = 0;
|
||||||
|
|
||||||
|
constructor(array, limit = 100) {
|
||||||
|
super(array);
|
||||||
|
this.#limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(ref) {
|
||||||
|
if (++this.fetchCount > this.#limit) {
|
||||||
|
throw new Error(`BoundedXRefMock: more than ${this.#limit} fetches.`);
|
||||||
|
}
|
||||||
|
return super.fetch(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createIdFactory(pageIndex) {
|
function createIdFactory(pageIndex) {
|
||||||
const pdfManager = {
|
const pdfManager = {
|
||||||
get docId() {
|
get docId() {
|
||||||
@ -285,6 +307,7 @@ class TestPdfsServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
BoundedXRefMock,
|
||||||
buildGetDocumentParams,
|
buildGetDocumentParams,
|
||||||
CMAP_URL,
|
CMAP_URL,
|
||||||
createIdFactory,
|
createIdFactory,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user