Skip to content

[[MM-70248] Bound plain text extraction to prevent heap exhaustion - #85

Closed
devinbinnie wants to merge 5 commits into
ledongthuc:masterfrom
mattermost:MM-70248
Closed

[[MM-70248] Bound plain text extraction to prevent heap exhaustion#85
devinbinnie wants to merge 5 commits into
ledongthuc:masterfrom
mattermost:MM-70248

Conversation

@devinbinnie

Copy link
Copy Markdown

Summary

GetPlainText accumulated all extracted text into an in-memory buffer with no upper bound. A small PDF whose FlateDecode content stream expands into a very large volume of text could therefore drive the heap far beyond the size of the file.

This PR caps total accumulated text at 1 MiB, which is the same value we truncate to on the server. Once the cap is reached, extraction stops appending and cancels the interpreter's context.

Ticket Link

https://mattermost.atlassian.net/browse/MM-70248

agarciamontoro and others added 5 commits July 16, 2026 16:07
Limit the recursion depth in readObject() to prevent excessive resource
consumption when parsing deeply nested PDF structures. Returns an error
if parsing past the limit.

Co-authored-by: JG Heithcock <jgheithcock@gmail.com>
* Change module name to github.com/mattermost/pdf

* Fix remaining mention to original repo
* Add context to text extraction API

Add context.Context to Interpret, Page.Content, Page.GetPlainText,
Reader.GetPlainText, and Reader.GetStyledTexts.

Context is checked at two levels:
- Per page boundary in the Reader-level functions, allowing cheap
  cancellation between pages.
- Per PDF operator inside Interpret, allowing fine-grained cancellation
  mid-stream even on single large pages.

Page.Content's signature changes from returning Content to
(Content, error) so cancellation errors can propagate. Callers that
don't need cancellation can pass context.Background(); existing
internal callers (readCmap, walkTextBlocks) are updated accordingly.

* Add context cancellation tests

Test pre-cancelled, background, and mid-extraction cancellation
for both Reader.GetPlainText and Reader.GetStyledTexts.

Uses a triggerCtx helper that closes its Done channel after a
configurable number of polls, enabling deterministic mid-loop
cancellation without timing dependencies.

* Acknowledge Interpret error in walkTextBlocks

Interpret now returns an error after gaining context support.
walkTextBlocks uses context.Background() so the error can never
be non-nil, but leaving the return value unhandled triggers
errcheck. Use a blank identifier to make the intent explicit.
* MM-69725: Add opt-in tests for problematic PDF fixtures (#4)

* Additional tests for problematic PDFs

* MM-69725: Address fixture test review feedback

Avoid counting retained plaintext in allocation probes, preserve depth-limit
errors for assertions, and align the fixture tooling defaults and failures.

* MM-69725: Adversarial test fixes (#5)

* Propagate ctx through lexer reload and CMap parsing

The buffer.reload() method is called for every chunk read from a stream,
but previously had no way to respect context cancellation. A malicious
PDF with a huge ToUnicode CMap or a giant literal string token could hold
the goroutine indefinitely even after the caller cancelled.

Changes:
- Add ctx field to buffer; reload() checks it before each read so
  long tokens and stream decodes stay cancellable.
- Thread ctx through Interpret → buffer so PostScript streams
  (CMap, content) honor cancellation at the lexer level.
- Add internal Font.encoder(ctx) / getEncoder(ctx) / charmapEncoding(ctx)
  and readCmap(ctx, …) so ToUnicode CMap parsing uses the caller's ctx
  instead of context.Background().
- GetPlainText and Content now call encoder(ctx) so the full extraction
  path is cancellable end-to-end.

* Cap Predictor Columns to prevent large up-front alloc

applyFilter allocates a ~2×Columns-byte buffer before any content is
read. A malicious PDF with a huge Columns value (e.g. 2^31-1) would
cause a multi-GB allocation before cancellation or EOF.

Add maxPredictorColumns = 1<<20 (1M columns, well above any real
image scanline or xref stream) and panic with a clear error if the
value is out of range.

Also update adversarial_pdfs/README.md to reflect the "gap under test"
framing and add a probe command for manual testing.

* Cap literal string tokens and fix AcroForm alloc threshold

String token cap:
readLiteralString and readHexString accumulate the entire token into a
growing slice with no upper bound. A malicious PDF can embed a huge
(...) or <...> string — either raw on disk or compressed via FlateDecode
— forcing an unbounded allocation before any cancel poll fires.
Add maxStringBytes = 128 MiB and panic with a clear error if either
accumulator exceeds it.

AcroForm test threshold:
The hardcoded 8 MiB limit in TestAdversarial_AcroFormStaysCheap was
calibrated for the medium-scale fixture (~2 MB, 10 K fields). At large
scale (50 K fields, ~9.4 MB), xref-table parsing allocates proportional
to object count, so the threshold must scale with the fixture. Use
len(data) + 2 MiB instead.

* Fix nested_content depth at small scale

depth=200 is below maxObjectDepth=1000, so the depth-limit test never
triggered at small scale. The fixture is a few KB regardless of depth,
so use 1200 (same as medium/large) across all scales.

* Convert Python script into Go

* Remove development tools and documents

* Add CI step to run adversarial tests

* Run CI on push to master, and all on PRs

* Enforce string byte cap on every append path

maxStringBytes was only checked in readLiteralString's default
branch. Escaped characters, octal escapes, and nested-parenthesis
bytes all append to tmp through other branches, so a payload built
from those (e.g. repeated \n escapes) could still grow the token
past the cap before ever tripping the check. Move the check to run
once per loop iteration, after the switch, so every append path is
bounded the same way.

* Make CMap range loops poll ctx for cancellation

Interpret only polls ctx between tokens, but readCmap's
end{codespacerange,bf{char,range}} handlers each loop n times within a
single token callback, where n comes straight from an
attacker-controlled int64 on the stack. A malicious ToUnicode CMap can
set n arbitrarily high, turning one callback into an unbounded busy loop
that no cancellation can interrupt. Check ctx.Err() on each iteration
and bail out via the existing ok=false path so these loops stay
cooperative with a canceled context.

* Trigger adversarial tests on real stream reads

Fix the outdated mention to the Python script.

The four cancel-cheaply tests used a fixed 200us context timeout that,
measured against unrestricted runs taking 10-340ms, was expiring before
GetPlainText ever reached the fixture's adversarial content. The tests
were passing by canceling before parsing started, not by exercising
cancellation mid-parse.

Replace the fixed timeout with contentStreamTrigger, an io.ReaderAt
wrapper that cancels only after it observes a run of consecutive,
sequentially-offset reads: the signature of the lexer actually
streaming through a stream's bytes, as opposed to the scattered
small reads used to resolve the xref table, page tree, and fonts.
Cancellation latency and allocations are then measured from that
confirmed point forward, giving a deterministic signal instead of
one that races an arbitrary deadline against unrelated setup cost.

---------

Co-authored-by: JG Heithcock <jgheithcock@gmail.com>
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