Skip to content

Walk a page's resources once, and charge for a picture before making it - #38

Merged
tannevaled merged 2 commits into
mainfrom
fix/bound-what-images-allocates
Aug 30, 2026
Merged

Walk a page's resources once, and charge for a picture before making it#38
tannevaled merged 2 commits into
mainfrom
fix/bound-what-images-allocates

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes #37.

[2026-08-30 19:41:01 CEST]

The cause, in one sentence

imagesIn walked a page's resources as a tree when they are a graph: in gh-openpdf/openpdf-core_src_test_resources_pdfsmartcopy_bec.pdf the page's /Resources names 37 form XObjects and every one of them names that same /Resources dictionary straight back, so the walk visited 37 dictionaries at the first level, 1 369 at the second, 50 653 at the third — 37 to the eighth at the depth limit, three and a half million million — and decoded each of the page's 40 pictures once per visit, at four bytes a pixel.

The offending document is openpdf-core_src_test_resources_pdfsmartcopy_bec.pdf, 208 584 bytes, three pages, fa1554ec98a2ca39. Found by running the whole gh-openpdf population under a 2 GB watchdog, one document per process, so a bad one failed instead of taking the machine down.

Measured by walking the tree one level at a time:

depth resource dictionaries visited image visits declared pixels
0 1 40 749 299
1 37 1 520 28 473 362
2 1 369 56 280 1 054 263 693 (4.2 GB)
3 50 653 2 082 400 39 008 505 940 (156 GB)

Depth three alone is 156 GB, and the limit is eight. That is the 87 GB.

The "drawn 511 times" finding is the same defect, and it is fixed

fr-cerfa/cerfa_10103.pdf does not decode its pictures 511 times because they are drawn 511 times. That page's forms fan out by two, and 2⁰ + 2¹ + … + 2⁸ = 511. Measured on it: resource dictionaries visited by depth are 1, 2, 4, 8, 16, 32, 64, 128, 256 — 511 in all — and 1 022 image visits for 2 distinct pictures.

So it is not a separate change and does not need a separate issue. After this PR render.Images on that page hands back 3 entries (two pictures and a mask), each decoded once. pdfsmartcopy_bec.pdf hands back 74 a page, immediately, with no watchdog.

What changed

1. Enter each XObject once, by its reference (firstVisit). A resource graph is walked as a graph. This alone removes the exponent, and it also stops the same picture reached by two forms coming back as two identical entries under the same name.

2. Charge a picture before it is made (afford, ErrTooMuchToDecode). Walked once, a document may still name more picture than the machine holds — and it names it rather than carrying it, since decodeBase makes four bytes for every pixel of the declared /Width × /Height whatever the stream turns out to hold. So the declared size is taken out of a budget before decodeBase is called, masks included, and a page past the budget is refused whole with an error naming the picture and the limit. Nothing is returned with the refusal: half a page's pictures would be read as all of them by anything counting.

3. Read what a codec says it holds before handing it the bytes (affordDecoded, jpegSize, jpxSize). This closes a hole in (2) that I found while building the fixture, and it is why this PR is two commits rather than one. A codestream carries its own size, need not agree with the dictionary, and this package already prefers the codestream's. image/jpeg allocates the whole picture the moment it reaches the start of scan — so a valid 8×8 JPEG of 376 bytes whose frame header is patched to claim 65 535 × 65 535 allocates four gigabytes (measured: totalAlloc delta 4 096 MB) and only then reports the scan data missing, while its dictionary is charged 64 pixels. Both codecs will state their size from the header alone without allocating, so that is read first. A codestream past the ceiling on one picture is refused; one larger than its dictionary pays the difference into the budget, so a page of pictures each declaring one pixel and each holding a large codestream cannot come to as much as it likes.

This third part is the only change that touches Page, which was never affected by the walk (it is bounded by maxOperations, and all three pages of bec.pdf draw). It should not allocate four gigabytes for a 376-byte file either.

Where the limit comes from

maxImagesPixels = 256 << 20 — 268 435 456 pixels, a gigabyte of RGBA.

Over the corpus of 2 268 real forms, 10 659 pages, of which 2 111 draw a picture at all, counting distinct pictures per page as the fixed walk counts them:

declared pixels decoded
median 90 048 0.4 MB
90th centile 1 015 442 4.1 MB
99th centile 8 699 840 34.8 MB
largest page in the corpus 31 814 093 127 MB
the limit 268 435 456 1 024 MB

Not one page in the corpus names as much as a sixth of the limit; the document that started this named enough for 87 GB. The per-picture ceiling of maxImagePixels = 64 << 20 is unchanged and now applies to what a codec claims as well as to what a dictionary declares.

Tests

The fixture is built in the test, not carried: sharedResourcesPage is pdfsmartcopy_bec.pdf reduced to its shape — a page whose /Resources names n forms, every one of which names that same dictionary back. No corpus document is copied into the repository.

  • TestASharedResourceDictionaryIsWalkedOnce — the reduced fixture with three small pictures. Asserts three come back, once each, in name order. Without firstVisit it is 262 143 (3 × Σ4ᵈ), which the test reports.
  • TestImagesRefusesAPageNamingMorePictureThanItWillDecodethe same fixture, with pictures large enough that what it names exceeds the budget. Asserts errors.Is(err, ErrTooMuchToDecode), that nothing comes back with it, and that the message names both the offending picture and the limit. This is the refusal, asserted on the fixture.
  • TestAPictureOfAnImpossibleSizeIsRefusedBeforeItIsMade, TestAMaskIsChargedForToo, TestARefusalInsideAFormStopsTheWalk — a declared width past the whole budget on either side alone (so nothing overflows), a mask charged like the picture it is, and a refusal several forms down returning up rather than carrying on.
  • TestACodestreamIsNotBelievedBeforeItIsMeasured — the 376-byte lying JPEG. It asserts the decoder is never reached, not merely that nothing came back: nothing came back before this change either, once four gigabytes had been allocated and the decode had then failed. Reaching the decoder is the allocation, so "never reached" is the whole of the claim that the check happens before it.
  • TestAJPXCodestreamIsMeasuredBeforeItIsDecoded — the same guard on the other codec, likewise asserting the decoder is not reached.
  • TestWhatACodecSaysItHoldsIsPaidForToo — nine cases over affordDecoded directly, including both directions of "a page keeps no picture and spends nothing".
  • TestAnOrdinaryPageIsNotRefused and TestFormsMayStillNestAsDeepAsTheyMay — the other direction. A bound everything reaches is a broken library.

Each new test was run against the code with its own fix neutered, and each fails: 262 143 pictures without firstVisit, err=<nil> without afford, "handed the bytes to the decoder anyway" without the codec ceiling.

TestAFormThatHoldsItselfStops still passes unchanged.

Whole-corpus check, with the change in

render.Images over every page of all 2 268 documents, in one process under a 3 GB watchdog:

files=2268 opened=2240 pages=10659 pictures=16220 refused=0 peakHeap=398MB

No refusal, no panic, and the same 16 220 pictures before and after the codec guard — so nothing real is dropped by reading a header first. Before the change, one document of those 2 268 was still allocating at 87 GB.

One note on DCTDecode, not acted on here

Relevant to the finding that JPEG is the weak spot in the per-image comparison (117 exact of 443, against 3 198 of 4 198 for raw samples): decodeJPEG takes the size from the codestream and not from the dictionary when the two disagree, and silently returns the codestream's picture at the codestream's size. Any comparison that lines a picture up by the dictionary's /Width × /Height will therefore compare the wrong grid for exactly those files. Worth checking before more is read into the JPEG numbers. Not changed here.

Gate

go vet, gofmt -l empty under Go 1.27.0 (local 1.26.4 disagrees with CI), go test -race, exact 100.0% statement coverage under both 1.26.4 and 1.27.0, and all nine cross-compile targets, CGO_ENABLED=0, GOWORK=off.

render.Images(d, 1) on openpdf's pdfsmartcopy_bec.pdf — 208 KB, three
pages — was killed at 87 GB resident and still climbing.

THE CAUSE. A page's resources are a GRAPH, not a tree, and imagesIn
walked every path through it. In that file the page's /Resources names
37 form XObjects and every one of them names that same /Resources
dictionary straight back, so the walk visits 37 dictionaries at the
first level, 1 369 at the second, 50 653 at the third — 37 to the
eighth at the depth limit of 8, which is three and a half million
million — and decodes each of the page's 40 pictures once per visit,
four bytes a pixel. Depth three alone is 156 GB of pixels.

That is also the whole of the neighbouring finding. The three pictures
of fr-cerfa/cerfa_10103.pdf were not decoded 511 times because they are
DRAWN 511 times: that page's forms fan out by two, and the sum of two
to the power nought through eight is 511. Measured here: 1 022 visits
for 2 distinct pictures, over resource dictionaries counted
1, 2, 4, 8, 16, 32, 64, 128, 256 by depth.

THE FIX. Enter each XObject once, by its reference. bec.pdf now hands
back 74 pictures a page and cerfa_10103.pdf 3, immediately, and the
same picture reached by two forms comes back once rather than as two
identical entries under the same name.

THE BOUND. Walked once, a document may still name more picture than
the machine will hold, and it NAMES it rather than carrying it:
decodeBase makes four bytes for every pixel of the declared /Width by
/Height whatever the stream holds. So the declared size is charged
against a budget BEFORE the picture is decoded, and a page past it is
refused whole with ErrTooMuchToDecode naming the picture and the
limit. Masks are charged too — a mask is a picture in its own right.

WHERE THE LIMIT COMES FROM. Over the 2 268 real forms of the corpus,
10 659 pages, 2 111 of which draw a picture at all: the page naming
the most comes to 31 814 093 pixels, 127 MB decoded; the median is
90 048; the 99.9th centile is the maximum. Not one page names as much
as a sixth of the 256 Mi-pixel limit, which is a gigabyte of RGBA.

Page is untouched and was never affected: it is bounded by
maxOperations, and all three pages of bec.pdf draw.
The budget of the commit before this one charges a picture for the
/Width and /Height its DICTIONARY declares. A codestream carries its
own size and need not agree, and where they disagree this package
already prefers the codestream's — so the budget had a hole a small
file goes straight through.

image/jpeg makes the whole picture the moment it reaches the start of
scan, before it looks at any scan data. A valid 8x8 JPEG of 376 bytes
whose frame header is altered to claim 65 535 by 65 535 therefore
allocates FOUR GIGABYTES and only then reports the scan data missing.
Measured: totalAlloc delta 4 096 MB for a 376-byte input. The
dictionary may say 8 by 8 and be charged 64 pixels for it.

Both codecs will say how large they are from the header alone and
without allocating — image/jpeg through jpeg.DecodeConfig, the JPEG
2000 reader through its own — so that is read first, and the bytes are
handed over only if what they claim can be afforded. A codestream past
the ceiling on a single picture is refused outright; one larger than
its dictionary pays the difference into the budget, so that a page of
pictures each declaring one pixel and each holding a large codestream
cannot come to as much as it likes.

The ceiling applies to Page too, which is the only change to Page here:
it keeps no picture, so it is not charged, but it should not allocate
four gigabytes for a 376-byte file either. A header nothing can be read
from is left alone: the decoder gives up on it long before it
allocates.

The fixture is that patched JPEG, built in the test rather than
carried. What it asserts is not that nothing came back — nothing came
back before this too, once the decoder had allocated and then failed —
but that the decoder is NEVER REACHED, which is the whole of the claim
that the check happens before the allocation.
@tannevaled
tannevaled merged commit 57f6127 into main Aug 30, 2026
1 check passed
@tannevaled
tannevaled deleted the fix/bound-what-images-allocates branch August 30, 2026 17:43
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.

render.Images: unbounded allocation on a real document (87 GB resident before the kill)

1 participant