Summary
The hostile-PDF hardening pass (audit of 4acb0560cffef7a49f6e460b7c76326906271959) closes the budget
bypasses a hostile document can reach directly: PDFCMSManager::setDocument now charges the caller's
budget for /DestOutputProfile streams and propagates PDFBudgetExceededException, and
PDFFlateDecodeFilter::getStreamDataLength stops at the decoder's own ceiling instead of inflating to
stream end. What is left is the structural half.
The budget currently reaches 9 of 73 getDecodedStream call sites. Everywhere else, decoding is
bounded only by the per-stream fallback (256 MiB / 256x, LoopLibCore/sources/pdfstreamfilters.cpp:46-63)
— not by cumulative decoded bytes, and not by elapsed time. A document that spreads its decompression
across many small streams stays inside the per-stream ceiling on every one of them and is never charged
cumulatively.
Evidence
Call sites that pass no budget (counts are per file at the audited revision):
LoopLibCore/sources/pdffont.cpp — 7 (font program streams, /ToUnicode, Type3 resources)
LoopLibCore/sources/pdfimage.cpp — 6
pdfcolorspaces.cpp — 2, pdfpattern.cpp — 2, pdfcatalog.cpp — 2, pdfaction.cpp — 2,
pdfrgbtocmykfixup.cpp — 2, pdfdocumentsanitizer.cpp — 2
pdfannotation.cpp, pdfcms.cpp, pdffunction.cpp, pdfrepairdiff.cpp — 1 each
The sharpest single example is LoopLibCore/sources/pdffunction.cpp:77: PDFFunction::createFunction
decodes a function stream selected by a document-controlled dictionary with
document->getDecodedStream(stream) and no budget, and it is exported
(LoopLibCore/sources/pdffunction.h:96), so closing it changes an exported signature. The same is true
of the image, colours-pace, pattern, action and catalog accessors.
docs/RESOURCE_BUDGETS.md already states the contract this gap violates: budget exhaustion throws
PDFBudgetExceededException, callers must treat it as an incomplete operation, and it must never be
reported as a normal pass or a successful output.
Why this is deferred rather than dropped
AGENTS.md: "Do not invent a public contract when a protected interface, schema, persistence format,
central type, or root build contract must change; stop and report the contract change." Threading a
budget into PDFFunction::createFunction and the object-access helpers behind the other sites changes
exported signatures in LoopLibCore. That decision belongs with the 1.0 contract-surface freeze, not
with a hardening commit.
Options (pick one and record it before implementing)
- Budget accessor on the document / object storage.
PDFDocument / PDFObjectStorage exposes the
budget it was read with; getDecodedStream(stream) consults it. Smallest diff at the 64 remaining call
sites. The accessor must fail closed (or use an explicit named fallback) when a document was built
without a budget — documents are also constructed synthetically by fixups, the builder and tests.
- Thread the parameter. Add
PDFProcessingBudget* to the exported helpers. Explicit, but touches
~64 call sites plus exported signatures, and every consumer rebuilds.
- Operation-scoped budget. One context object (session / page / operation) owns the budget and is
passed to the decode helpers. No per-call parameter, one object to thread; matches how
PDFJobContext::processingBudget() already works for scheduled work.
Acceptance criteria
- Every decode path reachable from a document-controlled dictionary (functions, images, colours spaces,
patterns, actions, annotations, catalogs) charges a document-, operation- or caller-scoped budget.
- Cumulative decoded bytes and elapsed time are enforced across a document, not only per stream: add one
fixture per closed path to scripts/resource_envelope/budget_exhaustion_corpus.py +
UnitTests/testdata/budget_exhaustion/manifest.json so UnitTestsBudgetCorpus proves it, and keep the
docs/RESOURCE_BUDGETS.md pool/kind tables current.
- No regression in
UnitTestsBudgetCorpus / UnitTestsBudgetExhaustion.
- The chosen option is recorded (ADR or
docs/RESOURCE_BUDGETS.md) before implementation.
Out of scope
The P0/P1 defects themselves — those are fixed on dev by the hardening change set. Re-verification
belongs with #476 (0.8.0-S09 security and privacy audit refresh).
Summary
The hostile-PDF hardening pass (audit of
4acb0560cffef7a49f6e460b7c76326906271959) closes the budgetbypasses a hostile document can reach directly:
PDFCMSManager::setDocumentnow charges the caller'sbudget for
/DestOutputProfilestreams and propagatesPDFBudgetExceededException, andPDFFlateDecodeFilter::getStreamDataLengthstops at the decoder's own ceiling instead of inflating tostream end. What is left is the structural half.
The budget currently reaches 9 of 73
getDecodedStreamcall sites. Everywhere else, decoding isbounded only by the per-stream fallback (256 MiB / 256x,
LoopLibCore/sources/pdfstreamfilters.cpp:46-63)— not by cumulative decoded bytes, and not by elapsed time. A document that spreads its decompression
across many small streams stays inside the per-stream ceiling on every one of them and is never charged
cumulatively.
Evidence
Call sites that pass no budget (counts are per file at the audited revision):
LoopLibCore/sources/pdffont.cpp— 7 (font program streams,/ToUnicode, Type3 resources)LoopLibCore/sources/pdfimage.cpp— 6pdfcolorspaces.cpp— 2,pdfpattern.cpp— 2,pdfcatalog.cpp— 2,pdfaction.cpp— 2,pdfrgbtocmykfixup.cpp— 2,pdfdocumentsanitizer.cpp— 2pdfannotation.cpp,pdfcms.cpp,pdffunction.cpp,pdfrepairdiff.cpp— 1 eachThe sharpest single example is
LoopLibCore/sources/pdffunction.cpp:77:PDFFunction::createFunctiondecodes a function stream selected by a document-controlled dictionary with
document->getDecodedStream(stream)and no budget, and it is exported(
LoopLibCore/sources/pdffunction.h:96), so closing it changes an exported signature. The same is trueof the image, colours-pace, pattern, action and catalog accessors.
docs/RESOURCE_BUDGETS.mdalready states the contract this gap violates: budget exhaustion throwsPDFBudgetExceededException, callers must treat it as an incomplete operation, and it must never bereported as a normal pass or a successful output.
Why this is deferred rather than dropped
AGENTS.md: "Do not invent a public contract when a protected interface, schema, persistence format,central type, or root build contract must change; stop and report the contract change." Threading a
budget into
PDFFunction::createFunctionand the object-access helpers behind the other sites changesexported signatures in
LoopLibCore. That decision belongs with the 1.0 contract-surface freeze, notwith a hardening commit.
Options (pick one and record it before implementing)
PDFDocument/PDFObjectStorageexposes thebudget it was read with;
getDecodedStream(stream)consults it. Smallest diff at the 64 remaining callsites. The accessor must fail closed (or use an explicit named fallback) when a document was built
without a budget — documents are also constructed synthetically by fixups, the builder and tests.
PDFProcessingBudget*to the exported helpers. Explicit, but touches~64 call sites plus exported signatures, and every consumer rebuilds.
passed to the decode helpers. No per-call parameter, one object to thread; matches how
PDFJobContext::processingBudget()already works for scheduled work.Acceptance criteria
patterns, actions, annotations, catalogs) charges a document-, operation- or caller-scoped budget.
fixture per closed path to
scripts/resource_envelope/budget_exhaustion_corpus.py+UnitTests/testdata/budget_exhaustion/manifest.jsonsoUnitTestsBudgetCorpusproves it, and keep thedocs/RESOURCE_BUDGETS.mdpool/kind tables current.UnitTestsBudgetCorpus/UnitTestsBudgetExhaustion.docs/RESOURCE_BUDGETS.md) before implementation.Out of scope
The P0/P1 defects themselves — those are fixed on
devby the hardening change set. Re-verificationbelongs with #476 (0.8.0-S09 security and privacy audit refresh).