Skip to content

fix(security): bound quadratic array-stream decoding in is_pdf_too_complex (SEC-146) - #10

Open
anurag6569201 wants to merge 1 commit into
qa/agent-unstructured-io-unstructured/pr-10-4437/basefrom
qa/agent-unstructured-io-unstructured/pr-10-4437/head
Open

anurag6569201 wants to merge 1 commit into
qa/agent-unstructured-io-unstructured/pr-10-4437/basefrom
qa/agent-unstructured-io-unstructured/pr-10-4437/head

Conversation

@anurag6569201

Copy link
Copy Markdown

Summary

Fixes SEC-146. is_pdf_too_complex() in unstructured/partition/pdf.py re-implemented pypdf's array-based /Contents decoding using the same quadratic raw_data += obj.get_data() accumulation that pypdf patched under CVE-2026-33123 / GHSA-qpxp-75px-xjcp ("Inefficient decoding of array-based streams"). A crafted PDF whose page /Contents is an array of many small stream objects could force excessive CPU/memory. This function runs on every partitioned PDF, so it sits on the untrusted-input path (pdf.py:309).

Per the ticket, this keeps the intentional lightweight raw-bytes approach (added in Unstructured-IO#4268 to cheaply detect vector-heavy CAD/engineering PDFs) — it does not switch to pypdf's expensive ContentStream. It just makes the accumulation efficient and bounded.

Changes

  • Accumulate into a bytearray with .extend() instead of rebinding a bytes object (amortized O(1) vs O(n²)).
  • Per-page caps, fail closed (a single pathological page → treat as too complex, skip PDFMiner):
    • max_raw_stream_bytes (50 MB) — decoded bytes per page, checked before each stream is copied so an oversized stream is never accumulated or regex-scanned.
    • max_content_stream_array_entries (10,000, pypdf's number) — bounds an array of many empty/tiny streams that a byte cap alone misses.
  • Document-level caps so total work is a property of the function, not the page count (pages can share one indirect /Contents array → tiny file, unbounded scan). Charged incrementally per stream so a mid-page decode error can't discard the accounting. Set far above any plausible real document and logged at warning:
    • max_total_stream_bytes (1 GB) — decoded bytes per document.
    • max_total_array_entries (1,000,000) — decoded entries per document (zero-byte entries never advance the byte total but still cost a decode).
  • Dereference an indirect /Contents before the array check — DictionaryObject.get (unlike __getitem__) does not resolve references, so an indirect array of streams was silently skipping the array branch entirely (both the heuristic and the caps). 87 of 1,105 corpus pages reach their content array this way.
  • Count operators with finditer instead of findall, so counting no longer allocates a match list proportional to stream size.
  • Bump pypdf to >=6.9.1 (from >=6.6.2) so the library's own code path is patched too; lock resolves 6.10.0.

Audit (AC4)

Grepped unstructured/partition/pdf.py and pdf_image/ for other +=-on-stream-bytes loops — none found; this was the only instance. A separate O(n²) string-accumulation pattern in unstructured/partition/html/transformations.py (element-merge loop) is out of SEC-146's scope (not on the PDF path) and is tracked in its own ticket.

Testing

Rewrote the regression tests around real PdfWriter/PdfReader fixtures (FlateDecode-compressed streams so the file stays small while decoded output is huge — the actual attack shape). Coverage: direct + indirect graphics-heavy arrays, a many-small-streams array (~2 MB file → ~900 MB decoded) that runs for minutes / OOMs on the pre-fix code and returns in ~0.05 s here, the entry cap, the pre-copy byte cap, the cross-page byte and entry budgets, and budget survival across a mid-page decode error. The key tests were confirmed to fail on the pre-fix code.

  • Full test_unstructured/partition/pdf_image/test_pdf.py: 178 passed, 1 skipped
  • ruff check + ruff format --check: clean

Acceptance criteria

  • is_pdf_too_complex accumulates via bytearray, not bytes +=
  • Total-length bounds cap worst-case work on array-based content streams (per-page + per-document, bytes + entries)
  • Regression test with a many-small-streams array PDF completes in bounded time/memory (fails on the old code)
  • Repo audited for other +=-on-stream-bytes copies; findings fixed or ticketed
  • pypdf dependency confirmed ≥ 6.9.1

🤖 Generated with Claude Code

Review in cubic

Source merge-base: 8c4592a136b8abfa2e0ead78a45c3bfcf29479f8
Source head: c8b495b09429bcfe5de6ae429bbf3b947f58bd4e

@shipwright-agent

Copy link
Copy Markdown

⛔ Shipwright · Blocked

Recommendation: do not merge PR #10 · Tier T3
Checks: 0 total · 0 needing attention

Next step: resolve the blocking findings before merge.

Findings (9)

  • CRITICAL The per-page byte cap is checked as 'len(accumulated) + len(chunk) > max_raw_stream_bytes' before extending, but 'total_raw_bytes' is incremented before this check. · unstructured/partition/pdf.py:770
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL The document-wide entry budget is charged as 'total_array_entries += len(contents)' before iterating, but the per-page entry cap check 'if len(contents) > max_content_stream_array_ · unstructured/partition/pdf.py:735
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL The 'except Exception: continue' on 'contents.get_object()' and on 'obj.get_data()' creates a fail-open path for 'MemoryError' and 'RecursionError'. · unstructured/partition/pdf.py:728
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • CRITICAL The per-page byte cap is enforced only after 'obj.get_data()' has fully decoded the stream into memory. · unstructured/partition/pdf.py:770
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The function now has 12 parameters, 6 of which are new 'max_*' caps with defaults spread across two locations (module constants and function signature). · unstructured/partition/pdf.py:633
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The array branch and standalone branch duplicate the byte-budget and cap-check logic with subtle differences: the array branch checks 'len(accumulated) + len(chunk) > max_raw_strea · unstructured/partition/pdf.py:750
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The 'except Exception: continue' around 'contents.get_object()' silently swallows all exceptions, including 'MemoryError', 'RecursionError', or a malformed indirect reference. · unstructured/partition/pdf.py:728
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • HIGH The 'except Exception: continue' inside the array stream loop skips a stream that raises any non-'LimitReachedError' exception, but the test 'test_is_pdf_too_complex_unreadable_str · unstructured/partition/pdf.py:760
    • Fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.
  • …and 1 more findings in the check details.

Fireworks usage: 20,354 input · 1,658 output · 22,012 total tokens · $0.0056 · 23s · 0 fix iteration(s)

Open the Shipwright check for full evidence and the audit bundle. Use /shipwright rerun to verify again.

# streams on the page are still inspected and charged.
try:
obj = item.get_object() if isinstance(item, IndirectObject) else item
if not hasattr(obj, "get_data"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The per-page byte cap is checked as 'len(accumulated) + len(chunk) > max_raw_stream_bytes' before extending, but 'total_raw_bytes' is incremented before this check.

Impact: The per-page byte cap is checked as 'len(accumulated) + len(chunk) > max_raw_stream_bytes' before extending, but 'total_raw_bytes' is incremented before this check. A stream that trips the per-page cap returns True immediately, so the document-wide accounting is irrelevant on that path. However, the standalone branch checks 'len(chunk) > max_raw_stream_bytes' after incrementing 'total_raw_bytes', and the array…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

if hasattr(obj, "get_data"):
raw_data = obj.get_data()
if hasattr(contents, "get_object"):
contents = contents.get_object()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The document-wide entry budget is charged as 'total_array_entries += len(contents)' before iterating, but the per-page entry cap check 'if len(contents) > max_content_stream_array_

Impact: The document-wide entry budget is charged as 'total_array_entries += len(contents)' before iterating, but the per-page entry cap check 'if len(contents) > max_content_stream_array_entries' returns True before the document budget is charged. This is fine for the per-page cap, but the document budget is only charged for arrays that pass the per-page cap. A document with many pages each containing an array just unde…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

# streams on the page are still inspected and charged.
try:
obj = item.get_object() if isinstance(item, IndirectObject) else item
if not hasattr(obj, "get_data"):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · CRITICAL

The per-page byte cap is enforced only after 'obj.get_data()' has fully decoded the stream into memory.

Impact: The per-page byte cap is enforced only after 'obj.get_data()' has fully decoded the stream into memory. A single crafted stream that decompresses to 900 MB will cause a 900 MB allocation before the 'len(accumulated) + len(chunk) > max_raw_stream_bytes' check can reject it. The cap bounds the accumulated buffer but not the peak memory of a single stream decode, so the CVE-2026-33123 memory-exhaustion vector is onl…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

f"entries, exceeding the limit of "
f"{max_content_stream_array_entries}. "
"Flagging PDF as too complex for text extraction."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The array branch and standalone branch duplicate the byte-budget and cap-check logic with subtle differences: the array branch checks 'len(accumulated) + len(chunk) > max_raw_strea

Impact: The array branch and standalone branch duplicate the byte-budget and cap-check logic with subtle differences: the array branch checks 'len(accumulated) + len(chunk) > max_raw_stream_bytes' before extending, while the standalone branch checks 'len(chunk) > max_raw_stream_bytes' after assigning. The array branch charges 'total_raw_bytes' before the per-page cap check, while the standalone branch also charges bef…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

f"Content-stream array entries exceed {max_total_array_entries} "
f"by page {page_index + 1}. "
"Flagging PDF as too complex for text extraction."
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The 'except Exception: continue' inside the array stream loop skips a stream that raises any non-'LimitReachedError' exception, but the test 'test_is_pdf_too_complex_unreadable_str

Impact: The 'except Exception: continue' inside the array stream loop skips a stream that raises any non-'LimitReachedError' exception, but the test 'test_is_pdf_too_complex_unreadable_stream_does_not_skip_rest_of_page' only covers 'NotImplementedError'. A stream that raises 'MemoryError' or 'RecursionError' during 'get_data()' is silently skipped, and if all streams on the page raise, the page is treated as not complex and…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

if hasattr(obj, "get_data"):
raw_data = obj.get_data()
if hasattr(contents, "get_object"):
contents = contents.get_object()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Shipwright · HIGH

The document-wide entry budget 'max_total_array_entries' is charged as 'total_array_entries += len(contents)' before the per-page entry cap check, but the per-page cap check return

Impact: The document-wide entry budget 'max_total_array_entries' is charged as 'total_array_entries += len(contents)' before the per-page entry cap check, but the per-page cap check returns True before the document budget is charged. This means a single page with an array over the per-page cap returns True without charging the document budget, which is fail-closed and acceptable. However, the document budget is only charged…

Suggested fix: Review the cited evidence, fix the risk if confirmed, and rerun Shipwright.

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.

1 participant