Datagrunt 4.6.9: Bound Embedded Image Codestream Decoding

October 31, 2026by Martin Graham

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:

  1. PDF Object Dictionary Metadata: High-level /Width and /Height dictionary keys.
  2. Internal Codestream Headers: Low-level headers inside the raw compressed bytes (e.g., the SIZ marker 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’s SIZ marker 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: 0

Infrastructure & Wheel Build Updates

In addition to the PDF engine security hardening:

  • macOS arm64 CI Runners: Upgraded wheel building workflows from macos-14 to macos-15 for aarch64-apple-darwin arm64 macOS PyPI wheels (#399, closing #398).

Upgrade Today

Upgrade to Datagrunt 4.6.9 using uv or pip:

uv pip install --upgrade "datagrunt[pdf]"