Datagrunt 4.6.9: Bound Embedded Image Codestream Decoding
Datagrunt 4.6.9 delivers critical defense-in-depth security and memory stability enhancements (#396). This release bounds raw embedded image codestream decoding before PyMuPDF or PDFium native engines touch image data, completely neutralizing PDF image decompression bombs (“decode bombs”).
The Problem: PDF Image Decompression Bombs
In PDF specifications, embedded images using advanced compression formats—specifically JPEG 2000 (/JPXDecode) and JBIG2 (/JBIG2Decode)—declare metadata in two distinct places:
- PDF Object Dictionary Metadata: High-level
/Widthand/Heightdictionary keys. - Internal Codestream Headers: Low-level headers inside the raw compressed bytes (e.g., the
SIZmarker in JPEG 2000 or page-information and region segments in JBIG2).
Malicious or corrupt PDFs can declare deceptive dictionary dimensions (e.g., /Width 10 /Height 10) while embedding an internal codestream that specifies massive dimensions (e.g., 50,000 × 50,000 pixels).
When reading image metadata, extracting embedded assets, or rasterizing pages, standard rendering engines (including PDFium and PyMuPDF/MuPDF) decode the raw codestream at its internal SIZ or segment dimensions.
During benchmarking, a crafted ~5 KB PDF file containing a single JPEG 2000 codestream bomb expanded to:
- 1.36 GB RAM allocation under PDFium
- 2.0+ GB RAM allocation under PyMuPDF
This massive memory expansion posed severe Out-Of-Memory (OOM) crash risks and Denial of Service (DoS) vulnerabilities for automated data pipelines processing untrusted PDF inputs.
How Datagrunt 4.6.9 Protects Your Pipelines
Datagrunt 4.6.9 introduces two pure-Python guard modules—image_guard and render_guard—that pre-screen embedded stream headers in pure Python before passing them to underlying C/C++ rendering engines.
1. Pure-Python Codestream Header Inspection (image_guard)
Before any engine decodes an embedded image, Datagrunt reads the raw stream bytes and parses its headers directly:
- JPEG 2000 (
/JPXDecode): datagrunt parses the codestream’sSIZmarker to read its width, height, component count, and tile count. - JBIG2 (
/JBIG2Decode): Performs a bounded segment walk across page-information and region segments (up to 4,096 segments) to evaluate bitmap dimensions.
2. Strict Fail-Closed Safety Bounds
An image is allowed to decode only if every filter is recognized and all internal codestream dimensions pass safety caps:
| Parameter | Safety Limit | Rationale |
|---|---|---|
| Max Decoded Pixels | 89,478,485 px (~89.5 Mpx) |
Derived from 1 GiB / 4 / 3 (Pillow’s MAX_IMAGE_PIXELS bound) |
| Max JPX Components | 4 components |
Prevents multi-channel memory multiplication |
| Max JPX Tiles | 4,096 tiles |
Prevents OpenJPEG tile header allocation overhead |
| Max Raw Stream Size | 1 GiB |
Bounds initial header copy memory footprint |
If an image header cannot be read, uses an unrecognized filter, or exceeds any safety bound, the image is skipped (never decoded) and a page warning is recorded.
Standard stream filters (FlateDecode, CCITTFaxDecode, LZWDecode, RunLengthDecode, and DCTDecode JPEG) remain unaffected as they are already bounded by PDF dictionary bounds or exported undecoded.
With engine="pymupdf", a page whose image is skipped is returned without its text, and the page warning says which image was skipped. The default pdfium engine keeps the page’s text. (PyMuPDF is removed in 5.0.)
3. Document-Wide Page Render Guard (render_guard)
When calling render_pages_as_images, a page render rasterizes every image drawn on that page.
To guarantee safety during whole-page rasterization, render_guard scans all images across the entire document before rendering begins:
- If any image in the PDF is identified as a decode bomb or exceeds safety limits, page rendering is safely blocked document-wide.
- A clear warning is logged (
No pages rendered: <reason>), protecting the process from crashing or running out of memory.
Code Example: Handling Protected PDF Extractions
Data pipelines using PDFReader or PDFWriter automatically benefit from these guards without code changes:
from datagrunt import PDFReader, PDFWriter
# 1. Parse a PDF containing a JPEG 2000 decode bomb
reader = PDFReader("untrusted_document.pdf")
doc_dict = reader.to_dicts()
# With the default engine the decode bomb image is skipped; page text and safe elements are preserved
page = doc_dict["document"]["pages"][0]
print(f"Page Classification: {page['classification']}")
if "warnings" in page:
for warning in page["warnings"]:
print(f"Warning: {warning}")
# Output:
# Warning: page 1 image 0: JPEG 2000 decodes to 50000 x 50000 = 2500000000 pixels, over the 89478485-pixel bound; skipped
# 2. Attempting whole-page rendering on a document with a decode bomb
writer = PDFWriter("untrusted_document.pdf")
images = writer.render_pages_as_images(output_dir="page_images")
# Safely returns empty list without allocating gigabytes of RAM
print(f"Rendered pages: {len(images)}") # Output: 0Infrastructure & Wheel Build Updates
In addition to the PDF engine security hardening:
- macOS arm64 CI Runners: Upgraded wheel building workflows from
macos-14tomacos-15foraarch64-apple-darwinarm64 macOS PyPI wheels (#399, closing #398).
Upgrade Today
Upgrade to Datagrunt 4.6.9 using uv or pip:
uv pip install --upgrade "datagrunt[pdf]"