Skip to content

[hardening] Thread the processing budget through exported decode signatures so budgets are never bypassed #558

Description

@mberrys

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)

  1. 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.
  2. Thread the parameter. Add PDFProcessingBudget* to the exported helpers. Explicit, but touches
    ~64 call sites plus exported signatures, and every consumer rebuilds.
  3. 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).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions