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.
This commit is contained in:
Alex committed 2026-09-14 17:45:43 +01:00
1 parent 3030aba39d
commit ad201f8318
3 files changed
+53 -3

No files matched your search

+2 -1
View File
@@ -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
+16 -2
View File
@@ -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
@@ -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 (