Skip to content

Harden PdfParser: bound serialization recursion and reject dangling object references - #10314

Open
Altafk960 wants to merge 5 commits into
python-pillow:mainfrom
Altafk960:harden/pdfparser-serialization-recursion
Open

Altafk960 wants to merge 5 commits into
python-pillow:mainfrom
Altafk960:harden/pdfparser-serialization-recursion

Conversation

@Altafk960

Copy link
Copy Markdown

Systematic hardening of PdfParser, closing the serialization half of the max_nesting work (#10023, #10093, #10125, #10127, #10185). Those changes bounded the document-graph traversals and the parse-side recursion (get_value/read_indirect max_nesting), but the serialize path (pdf_repr, PdfArray.__bytes__, PdfDict.__bytes__) still recursed with no depth bound, and two reachable lookups in read_indirect/rewrite_pages could still raise bare KeyErrors on malformed documents.

Changes (2 commits)

  1. Bound serialization recursion in pdf_repr — thread a depth counter through pdf_repr and raise PdfFormatError past a conservative MAX_NESTING = 100, mirroring the parse-side max_nesting guard. PdfArray.__bytes__/PdfDict.__bytes__ route through pdf_repr so the bound applies to bytes() as well. The bound is kept conservative because serialization runs several Python frames per nesting level at arbitrary call depth (for example inside Image.save(..., append=True)), and it keeps the stack far below Python's recursion limit. Output for values within the bound is byte-identical — the existing test_pdf_repr byte-exact assertions are unchanged.
  2. Raise PdfFormatError for dangling object references — read_indirect now validates the object id against the xref table via check_format_condition instead of leaking a bare KeyError, and rewrite_pages skips unresolvable /Parent references when rewriting an existing document, matching the check_format_condition idiom used throughout the parser.

Tests

3 regression tests: the serialization depth guard (pdf_repr and bytes(), at and just past MAX_NESTING), a dangling /Kids reference raising PdfFormatError instead of KeyError, and a page with a dangling /Parent surviving im.save(..., append=True) without an exception. The full test suite passes.

pdf_repr and the PdfArray/PdfDict __bytes__ methods recursed with no
depth bound, while parsing has had a max_nesting guard since python-pillow#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.
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.
@radarhere

Copy link
Copy Markdown
Member

It seems unexpected to have MAX_NESTING be entirely separate from the default behaviour of

Pillow/src/PIL/PdfParser.py

Lines 843 to 849 in f58197e

def get_value(
cls,
data: bytes | bytearray | memoryview | mmap.mmap,
offset: int,
expect_indirect: IndirectReference | None = None,
max_nesting: int = 900,
) -> tuple[Any, int | None]:

and
def read_indirect(self, ref: IndirectReference, max_nesting: int = 900) -> Any:

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.
@Altafk960

Copy link
Copy Markdown
Author

The two limits guard different operations, so I've made the separation explicit instead of sharing one number. Reading keeps the max_nesting defaults on get_value and read_indirect exactly as 13.0.0 shipped them. Writing stops at 100 because serialization spends several Python frames per nesting level and runs at arbitrary call depth mid-save — RecursionError lands around 250 levels, so a 900 bound on the write path could never fire. Unifying at 100 parses every file in Tests/ identically, but it would tighten the default 13.0.0 published, so I've renamed the constant to MAX_SERIALIZE_NESTING and left the read side alone. If you'd still rather have one shared limit, it's a two-line change. Release notes are now in both PRs as well.

(The three failing checks also fail on current main — the CodSpeed job (Python 3.15), run 40194988170, and the Docker jobs, run 40194988114 — so they don't look related to either PR.)

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants