From ad201f83184f48f8561198812e6f76e17cad99f1 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 14 Sep 2026 17:45:43 +0100 Subject: [PATCH] fix: refuse oversized TIFF/BMP attachments before converting them A deflate-compressed TIFF under 1 MB can declare 144 million pixels and take 1.2 GB to convert to PNG, and Pillow only warns below 179 million. Read the dimensions from the header and refuse images over 40 million pixels before any pixel data is decoded. Pillow's DecompressionBombError is now raised as DocumentParseError, so the upload fails once instead of being retried. --- docs/content/Guides/ocr.mdx | 3 +- docsgpt/parser/file/image_parser.py | 18 ++++++++-- .../file/test_image_vision_conversion.py | 35 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/docs/content/Guides/ocr.mdx b/docs/content/Guides/ocr.mdx index 2b185141..1a79eb84 100644 --- a/docs/content/Guides/ocr.mdx +++ b/docs/content/Guides/ocr.mdx @@ -156,7 +156,8 @@ itself is what a model reads. Models that take PDFs get the document; models that take only images get its pages as images; a text-only model is told it cannot read the file, and the message box warns before sending. Images work the same way with OCR off, and TIFF and BMP attachments are stored as PNG, -since model providers do not accept those formats. +since model providers do not accept those formats. A TIFF or BMP larger than +40 million pixels is refused instead of converted. Mixed documents — text pages with scanned pages among them — convert through anydoc, which reads the text pages and skips the scanned ones. With diff --git a/docsgpt/parser/file/image_parser.py b/docsgpt/parser/file/image_parser.py index e3382682..4a7b2942 100644 --- a/docsgpt/parser/file/image_parser.py +++ b/docsgpt/parser/file/image_parser.py @@ -18,6 +18,12 @@ from docsgpt.parser.file.base_parser import BaseParser, DocumentParseError # to PNG before it is stored for the model. VISION_CONVERTIBLE_MIME_TYPES = frozenset({"image/tiff", "image/bmp", "image/x-ms-bmp"}) +# Largest image re-encoded to PNG. A deflate TIFF under 1 MB can declare +# 144 million pixels and take 1.2 GB to convert, while Pillow only warns below +# 179 million. Vision models downscale far below this cap (Anthropic refuses +# more than 8000 px per side), so a larger image gains nothing. +MAX_CONVERTIBLE_PIXELS = 40_000_000 + def convert_image_to_png(file_obj: BinaryIO) -> Tuple[bytes, int]: """Re-encode the first frame of an image as PNG. @@ -30,12 +36,20 @@ def convert_image_to_png(file_obj: BinaryIO) -> Tuple[bytes, int]: source had. Only the first is kept. Raises: - DocumentParseError: If the bytes are not an image Pillow can decode. + DocumentParseError: If the bytes are not an image Pillow can decode, + or the image is larger than ``MAX_CONVERTIBLE_PIXELS``. """ from PIL import Image, UnidentifiedImageError try: with Image.open(file_obj) as image: + # Opening reads only the header; refuse before any pixels decode. + width, height = image.size + if width * height > MAX_CONVERTIBLE_PIXELS: + raise DocumentParseError( + f"The image is too large to convert ({width}×{height} pixels; the limit is " + f"{MAX_CONVERTIBLE_PIXELS // 1_000_000} million pixels). Resize it and upload it again." + ) frames = getattr(image, "n_frames", 1) image.seek(0) frame = image @@ -43,7 +57,7 @@ def convert_image_to_png(file_obj: BinaryIO) -> Tuple[bytes, int]: frame = frame.convert("RGBA" if "A" in frame.getbands() else "RGB") out = io.BytesIO() frame.save(out, format="PNG") - except (UnidentifiedImageError, OSError) as exc: + except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc: raise DocumentParseError(f"Could not read the image: {exc}") from exc return out.getvalue(), frames diff --git a/tests/parser/file/test_image_vision_conversion.py b/tests/parser/file/test_image_vision_conversion.py index 738636fa..d0225b22 100644 --- a/tests/parser/file/test_image_vision_conversion.py +++ b/tests/parser/file/test_image_vision_conversion.py @@ -52,10 +52,45 @@ class TestConvertImageToPng: assert Image.open(io.BytesIO(png)).format == "PNG" assert frames == 1 + def test_palette_tiff_is_stored_as_rgb_png(self): + buf = io.BytesIO() + Image.new("RGB", (40, 24), color=(0, 204, 51)).convert("P").save(buf, format="TIFF") + buf.seek(0) + + png, _ = convert_image_to_png(buf) + + decoded = Image.open(io.BytesIO(png)) + assert decoded.mode == "RGB" + assert decoded.getpixel((0, 0)) == (0, 204, 51) + def test_unreadable_bytes_raise_a_parse_error(self): with pytest.raises(DocumentParseError): convert_image_to_png(io.BytesIO(b"not an image")) + def test_image_over_the_pixel_limit_is_refused_before_decoding(self, monkeypatch): + # A deflate TIFF under 1 MB can decode to over a gigabyte, so the + # dimensions in the header are checked before any pixel data is read. + from PIL import TiffImagePlugin + + from docsgpt.parser.file import image_parser + + def decode(*args, **kwargs): + raise AssertionError("pixel data was decoded") + + monkeypatch.setattr(image_parser, "MAX_CONVERTIBLE_PIXELS", 500) + monkeypatch.setattr(TiffImagePlugin.TiffImageFile, "load", decode) + + with pytest.raises(DocumentParseError, match="40×24"): + convert_image_to_png(_encoded("TIFF")) + + def test_decompression_bomb_refusal_is_a_parse_error(self, monkeypatch): + # Pillow refuses images over twice MAX_IMAGE_PIXELS with an error that + # is not an OSError; it must fail the upload like any unreadable image. + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100) + + with pytest.raises(DocumentParseError): + convert_image_to_png(_encoded("TIFF")) + def test_only_formats_the_providers_reject_are_converted(self): assert {"image/tiff", "image/bmp"} <= VISION_CONVERTIBLE_MIME_TYPES assert not (