From de4405773cd9cfac641104b9d8ebf39c8c53b58a Mon Sep 17 00:00:00 2001 From: Altafk960 Date: Sun, 25 Oct 2026 23:09:07 +0530 Subject: [PATCH 1/3] Bound PDF serialization recursion in pdf_repr pdf_repr and the PdfArray/PdfDict __bytes__ methods recursed with no depth bound, while parsing has had a max_nesting guard since #10125. A nested value the parser accepts (up to max_nesting = 900) could exhaust the stack when re-serialized during Image.save(append=True). Thread a depth counter through pdf_repr and raise PdfFormatError past MAX_NESTING = 100, mirroring the parse-side guard; the bound is kept conservative because serialization runs several frames per level at arbitrary call depth. PdfArray.__bytes__ and PdfDict.__bytes__ now route through pdf_repr so the bound applies to bytes() as well. Output for values within the bound is byte-identical. --- Tests/test_pdfparser.py | 19 ++++++++++++++ src/PIL/PdfParser.py | 56 +++++++++++++++++++++++++++-------------- 2 files changed, 56 insertions(+), 19 deletions(-) diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index b8e9a434da7..e5913d3336e 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -7,8 +7,10 @@ import pytest from PIL.PdfParser import ( + MAX_NESTING, IndirectObjectDef, IndirectReference, + PdfArray, PdfBinary, PdfDict, PdfFormatError, @@ -290,3 +292,20 @@ def test_long_trailer_chain(tmp_path: Path) -> None: with PdfParser(f=b) as pdf: pass + + +def _deep_array(depth: int) -> PdfArray: + value = PdfArray([0]) + for _ in range(depth - 1): + value = PdfArray([value]) + return value + + +def test_pdf_repr_max_nesting() -> None: + # Serializing a deeply nested value must not exhaust the call stack. This is + # the write-side sibling of the max_nesting guard used while parsing. + pdf_repr(_deep_array(MAX_NESTING)) + with pytest.raises(PdfFormatError, match="maximum nesting depth exceeded"): + pdf_repr(_deep_array(MAX_NESTING + 1)) + with pytest.raises(PdfFormatError, match="maximum nesting depth exceeded"): + bytes(_deep_array(2 * MAX_NESTING)) diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 70bf71b2b1b..7263c832d6d 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -264,7 +264,7 @@ def __bytes__(self) -> bytes: class PdfArray(list[Any]): def __bytes__(self) -> bytes: - return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" + return pdf_repr(self) class PdfDict(_DictBase): @@ -302,17 +302,7 @@ def __getattr__(self, key: str) -> str | time.struct_time: return value def __bytes__(self) -> bytes: - out = bytearray(b"<<") - for key, value in self.items(): - if value is None: - continue - value = pdf_repr(value) - out.extend(b"\n") - out.extend(bytes(PdfName(key))) - out.extend(b" ") - out.extend(value) - out.extend(b"\n>>") - return bytes(out) + return pdf_repr(self) class PdfBinary: @@ -345,25 +335,35 @@ def decode(self, max_length: int = ImageFile.SAFEBLOCK) -> bytes: raise NotImplementedError(msg) -def pdf_repr(x: Any) -> bytes: +# Maximum object-nesting depth accepted when serializing PDF values. Serialization +# recurses several Python frames per nesting level and runs at arbitrary call depth +# (for example while an image is being saved), so this bound is kept conservative to +# keep the stack far below Python's recursion limit and prevent stack exhaustion. +MAX_NESTING = 100 + + +def pdf_repr(x: Any, _depth: int = 0) -> bytes: + if _depth > MAX_NESTING: + msg = "maximum nesting depth exceeded" + raise PdfFormatError(msg) if x is True: return b"true" elif x is False: return b"false" elif x is None: return b"null" - elif isinstance(x, (PdfName, PdfDict, PdfArray, PdfBinary)): + elif isinstance(x, (PdfName, PdfBinary)): return bytes(x) + elif isinstance(x, (PdfDict, dict)): + return _pdf_dict_repr(x, _depth) + elif isinstance(x, (PdfArray, list)): + return _pdf_array_repr(x, _depth) elif isinstance(x, (int, float)): return str(x).encode("us-ascii") elif isinstance(x, time.struct_time): return b"(D:" + time.strftime("%Y%m%d%H%M%SZ", x).encode("us-ascii") + b")" - elif isinstance(x, dict): - return bytes(PdfDict(x)) - elif isinstance(x, list): - return bytes(PdfArray(x)) elif isinstance(x, str): - return pdf_repr(encode_text(x)) + return pdf_repr(encode_text(x), _depth) elif isinstance(x, bytes): # XXX escape more chars? handle binary garbage x = x.replace(b"\\", b"\\\\") @@ -374,6 +374,24 @@ def pdf_repr(x: Any) -> bytes: return bytes(x) +def _pdf_array_repr(x: Any, _depth: int) -> bytes: + return b"[ " + b" ".join(pdf_repr(item, _depth + 1) for item in x) + b" ]" + + +def _pdf_dict_repr(x: Any, _depth: int) -> bytes: + out = bytearray(b"<<") + for key, value in x.items(): + if value is None: + continue + value = pdf_repr(value, _depth + 1) + out.extend(b"\n") + out.extend(bytes(PdfName(key))) + out.extend(b" ") + out.extend(value) + out.extend(b"\n>>") + return bytes(out) + + class PdfParser: """Based on https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/PDF32000_2008.pdf From d9cfdb190205640b366838bf8983afc38b4ba9ab Mon Sep 17 00:00:00 2001 From: Altafk960 Date: Sun, 25 Oct 2026 23:09:18 +0530 Subject: [PATCH 2/3] Raise PdfFormatError for dangling PDF object references read_indirect looked up the object id in the xref table without checking and leaked a bare KeyError for malformed documents, and rewrite_pages assumed every page carried a resolvable /Parent chain when appending to an existing document. Validate the reference through check_format_condition, and skip unresolvable parent references instead of crashing. Regression tests cover the dangling /Kids and dangling /Parent cases. --- Tests/test_pdfparser.py | 36 ++++++++++++++++++++++++++++++++++++ src/PIL/PdfParser.py | 10 ++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index e5913d3336e..8609099bbc3 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -309,3 +309,39 @@ def test_pdf_repr_max_nesting() -> None: pdf_repr(_deep_array(MAX_NESTING + 1)) with pytest.raises(PdfFormatError, match="maximum nesting depth exceeded"): bytes(_deep_array(2 * MAX_NESTING)) + + +def test_read_indirect_missing_object() -> None: + b = BytesIO() + with PdfParser(f=b, mode="wb") as pdf: + pdf.start_writing() + pdf.write_header() + + pdf.write_catalog() + pdf.write_xref_and_trailer() + + with PdfParser(f=b) as pdf: + # A reference to an object that is not in the xref table is malformed and + # must raise PdfFormatError rather than leaking a KeyError. + with pytest.raises(PdfFormatError, match="not found in xref table"): + pdf.read_indirect(IndirectReference(999, 0)) + + +def test_write_catalog_dangling_parent() -> None: + b = BytesIO() + with PdfParser(f=b, mode="wb") as pdf: + pdf.start_writing() + pdf.write_header() + + page_id = pdf.next_object_id(0) + pdf.write_page(page_id, Parent=IndirectReference(999, 0)) + pdf.pages = [page_id] + pdf.write_catalog() + pdf.write_xref_and_trailer() + + with PdfParser(f=b) as pdf: + pdf.start_writing() + pdf.write_header() + # Rewriting a page whose /Parent is a dangling reference must not crash + pdf.write_catalog() + pdf.write_xref_and_trailer() diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 7263c832d6d..729dfed488d 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -512,7 +512,7 @@ def rewrite_pages(self) -> None: for i, page_ref in enumerate(self.orig_pages): page_info = self.cached_objects[page_ref] del self.xref_table[page_ref.object_id] - pages_tree_nodes_to_delete.append(page_info[PdfName(b"Parent")]) + pages_tree_nodes_to_delete.append(page_info.get(PdfName(b"Parent"))) if page_ref not in self.pages: # the page has been deleted continue @@ -532,7 +532,9 @@ def rewrite_pages(self) -> None: processed_refs = [] while pages_tree_node_ref and pages_tree_node_ref not in processed_refs: processed_refs.append(pages_tree_node_ref) - pages_tree_node = self.cached_objects[pages_tree_node_ref] + pages_tree_node = self.cached_objects.get(pages_tree_node_ref) + if pages_tree_node is None: + break if pages_tree_node_ref.object_id in self.xref_table: del self.xref_table[pages_tree_node_ref.object_id] pages_tree_node_ref = pages_tree_node.get(b"Parent", None) @@ -1092,6 +1094,10 @@ def read_xref_table(self, xref_section_offset: int) -> int: return offset def read_indirect(self, ref: IndirectReference, max_nesting: int = 900) -> Any: + check_format_condition( + ref[0] in self.xref_table, + f"object ID {ref[0]} not found in xref table", + ) offset, generation = self.xref_table[ref[0]] check_format_condition( generation == ref[1], From ac6e59e2f0a1e71d258f08837727040f61392d29 Mon Sep 17 00:00:00 2001 From: Altafk960 Date: Mon, 26 Oct 2026 15:54:52 +0530 Subject: [PATCH 3/3] Name the serialization nesting limit separately from the parsing limit The serialization bound guards a different operation from the max_nesting defaults on get_value/read_indirect, so make the separation explicit and document both. Add release note entries for the serialization bound and the KeyError to PdfFormatError change. --- Tests/test_pdfparser.py | 8 ++++---- docs/releasenotes/13.1.0.rst | 14 ++++++++++++++ src/PIL/PdfParser.py | 4 ++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index 8609099bbc3..2f4edc055be 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -7,7 +7,7 @@ import pytest from PIL.PdfParser import ( - MAX_NESTING, + MAX_SERIALIZE_NESTING, IndirectObjectDef, IndirectReference, PdfArray, @@ -304,11 +304,11 @@ def _deep_array(depth: int) -> PdfArray: def test_pdf_repr_max_nesting() -> None: # Serializing a deeply nested value must not exhaust the call stack. This is # the write-side sibling of the max_nesting guard used while parsing. - pdf_repr(_deep_array(MAX_NESTING)) + pdf_repr(_deep_array(MAX_SERIALIZE_NESTING)) with pytest.raises(PdfFormatError, match="maximum nesting depth exceeded"): - pdf_repr(_deep_array(MAX_NESTING + 1)) + pdf_repr(_deep_array(MAX_SERIALIZE_NESTING + 1)) with pytest.raises(PdfFormatError, match="maximum nesting depth exceeded"): - bytes(_deep_array(2 * MAX_NESTING)) + bytes(_deep_array(2 * MAX_SERIALIZE_NESTING)) def test_read_indirect_missing_object() -> None: diff --git a/docs/releasenotes/13.1.0.rst b/docs/releasenotes/13.1.0.rst index 5edc0d28693..9134b8e3ef9 100644 --- a/docs/releasenotes/13.1.0.rst +++ b/docs/releasenotes/13.1.0.rst @@ -61,3 +61,17 @@ Blurring I;16 images :py:class:`~PIL.ImageFilter.GaussianBlur` and :py:class:`~PIL.ImageFilter.BoxBlur` now support I;16, I;16L, I;16B and I;16N images. + +Bounding recursion when writing PDFs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Serializing a deeply nested value with ``PIL.PdfParser`` could exhaust the call +stack and raise ``RecursionError``. Writing now stops at a fixed nesting depth +and raises ``PdfFormatError`` instead. + +Rejecting dangling object references +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Reading an indirect object that is missing from the xref table, or rewriting a +PDF whose page tree is missing nodes, now raises ``PdfFormatError`` rather than +a ``KeyError``. diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 729dfed488d..67070542d96 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -339,11 +339,11 @@ def decode(self, max_length: int = ImageFile.SAFEBLOCK) -> bytes: # recurses several Python frames per nesting level and runs at arbitrary call depth # (for example while an image is being saved), so this bound is kept conservative to # keep the stack far below Python's recursion limit and prevent stack exhaustion. -MAX_NESTING = 100 +MAX_SERIALIZE_NESTING = 100 def pdf_repr(x: Any, _depth: int = 0) -> bytes: - if _depth > MAX_NESTING: + if _depth > MAX_SERIALIZE_NESTING: msg = "maximum nesting depth exceeded" raise PdfFormatError(msg) if x is True: