mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +00:00
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:
1 parent
3030aba39d
commit
ad201f8318
3 files changed
+53
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user