Skip to content

Bound object nesting depth and stream decoding; detect page-tree cycles - #843

Open
k00ni wants to merge 3 commits into
masterfrom
fix/recursion-related
Open

k00ni wants to merge 3 commits into
masterfrom
fix/recursion-related

Conversation

@k00ni

@k00ni k00ni commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Type of pull request

  • Bug fix (involves code and configuration changes)
  • New feature (involves code and configuration changes)
  • Documentation update
  • Something else

About

Keeps the parser within bounded memory and time on malformed or degenerate input, without changing behaviour for well-formed PDFs.

Details

  • Nesting depth limit. getRawObject() recurses once per nested array or dictionary level. A new configurable limit (Config::setMaxNestingDepth(), default 5000) bounds this; a container nested beyond the limit is skipped so parsing returns instead of recursing without bound. Regular PDFs nest far below this, so their output is unchanged.
  • Page-tree cycle detection. Pages::getPages() now tracks visited nodes, so a /Pages tree that references one of its ancestors returns the pages found so far instead of recursing indefinitely.
  • Decode limit for all filters. Config::setDecodeMemoryLimit() previously bounded only FlateDecode; it now also bounds LZWDecode and RunLengthDecode. The default (0 = unlimited) is unchanged.

Additional notes

  • New config option setMaxNestingDepth() / getMaxNestingDepth().
  • Tests: a new RobustnessTest plus characterization tests in existing test classes that pin the behavior for well-formed input.
  • Docs: README.md and doc/CustomConfig.md describe the new option and setDecodeMemoryLimit; added ISO 32000-1 section references in the touched code.
  • Backwards compatible: default behavior is unchanged, except that objects nested beyond 5000 levels are no longer parsed in full.

@j0k3r: I hope it isn't too much to review. There are a lot of comments too and I added a couple more tests to make sure nothing breaks in production. If its too much code, please let me know, so I cut it up in smaller pieces. I combined them, because they all target issues which belong to the same error class ("malformed input leads parser to eat all memory").


CC @blackrose-0xday

k00ni and others added 2 commits September 22, 2026 18:26
- Add a configurable maximum nesting depth for arrays and dictionaries
(Config::setMaxNestingDepth, default 5000). A container nested beyond
the limit is skipped, so parsing returns instead of recursing without
bound.
- Detect cycles in the /Pages tree so Pages::getPages() no longer
recurses indefinitely on a self-referential tree.
- Apply the existing decodeMemoryLimit to LZWDecode and RunLengthDecode
(previously honoured only by FlateDecode).
- Add regression and characterization tests, document the new option and
setDecodeMemoryLimit (README, doc/CustomConfig.md), and add ISO 32000-1
section references.
- Refine documentation

Co-Authored-By: Claude Opus 4.8 <[email protected]>
@k00ni
k00ni requested a review from j0k3r September 22, 2026 16:48
@k00ni k00ni self-assigned this Sep 22, 2026
@k00ni k00ni added the fix label Sep 22, 2026

@j0k3r j0k3r left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me but there is only that ancestorRefs which might become huge for an untrusted PDF and break PHP memory.

It seems that using a SplObjectStorage is better to track the active path without copying it per level

Comment thread src/Smalot/PdfParser/Pages.php Outdated
Comment thread src/Smalot/PdfParser/Pages.php Outdated
Comment thread src/Smalot/PdfParser/Pages.php Outdated
Comment thread src/Smalot/PdfParser/Pages.php Outdated
@k00ni

k00ni commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Thank you very much!

You were right, the per-level array copy made memory quadratic in the page-tree depth (a chain of 4000 /Pages nodes needed over 400 MB, 10000 nodes exhausted 1 GB). Switched to a single SplObjectStorage shared by all levels, released in a finally block as you suggested.

One deviation though: I used offsetSet/offsetExists/offsetUnset instead of attach/contains/detach, because those three are deprecated as of PHP 8.5. Also added a regression test with a 10000-level page tree.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants