From 29a5f4b63d0414b8c288ed22487ab0470b24de9f Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Mon, 10 Aug 2026 17:24:01 +0100 Subject: [PATCH] Respond to images with a huge number of pixels An image with a small file size can decode to a huge number of pixels. Pillow refuses to open such an image, so the mock raised an uncaught ``DecompressionBombError`` instead of returning a response. Against a real database: * ``POST /targets`` returns ``ImageTooLarge`` above 37748736 pixels, whatever the image's file size, aspect ratio or color space. * The Query API applies no pixel count limit, only its existing maximum width and height of 30000. The mock now opens images with Pillow's decompression bomb protection disabled, and applies the ``POST /targets`` limit. This also fixes an unrelated ``ZeroDivisionError`` raised when rating an image of a single color, which the new tests need in order to make a small file with many pixels. Fixes #3378 Co-Authored-By: Claude Opus 5 (1M context) --- docs/source/contributing.rst | 5 ++ newsfragments/decompression-bomb-image.change | 2 + .../single-color-image-rating.change | 1 + spelling_private_dict.txt | 1 + src/mock_vws/_image_opening.py | 47 ++++++++++++ .../_query_validators/image_validators.py | 8 +- src/mock_vws/_services_validators/__init__.py | 2 + .../_services_validators/image_validators.py | 49 ++++++++++-- src/mock_vws/image_matchers.py | 7 +- src/mock_vws/target.py | 5 +- src/mock_vws/target_raters.py | 12 ++- tests/mock_vws/test_add_target.py | 76 ++++++++++++++++++- tests/mock_vws/test_query.py | 23 +++++- tests/mock_vws/utils/__init__.py | 39 ++++++++++ 14 files changed, 258 insertions(+), 19 deletions(-) create mode 100644 newsfragments/decompression-bomb-image.change create mode 100644 newsfragments/single-color-image-rating.change create mode 100644 src/mock_vws/_image_opening.py diff --git a/docs/source/contributing.rst b/docs/source/contributing.rst index a902f2af8..0ce738975 100644 --- a/docs/source/contributing.rst +++ b/docs/source/contributing.rst @@ -187,6 +187,11 @@ This is not the case. The documentation page `Vuforia Query Web API`_ states "Maximum image size: 2.1 MPixel. 512 KiB for JPEG, 2MiB for PNG". However, JPEG images up to 2MiB are accepted. +There is no documented limit on the number of pixels in an image, but ``POST /targets`` returns ``ImageTooLarge`` for an image with more than 37748736 pixels, whatever its file size, aspect ratio or color space. +An image of a single color has a tiny file size whatever its dimensions, which is how this limit is reached. +The Query API applies no such limit. +It applies only its maximum width and height of 30000 pixels. + The ``request_count`` in a database summary is always ``0``. The documentation for the target summary report says "Note: tracking_rating and ``reco_rating`` are provided only when status = success.". diff --git a/newsfragments/decompression-bomb-image.change b/newsfragments/decompression-bomb-image.change new file mode 100644 index 000000000..edb07bcd2 --- /dev/null +++ b/newsfragments/decompression-bomb-image.change @@ -0,0 +1,2 @@ +Return a response, rather than raising an uncaught ``PIL.Image.DecompressionBombError``, when an image with a small file size but a huge number of pixels is given to ``POST /targets`` or ``POST /v1/query``. +As real Vuforia does, ``POST /targets`` now returns the ``ImageTooLarge`` result code for an image with more than 37748736 pixels, and the Query API applies no pixel count limit. diff --git a/newsfragments/single-color-image-rating.change b/newsfragments/single-color-image-rating.change new file mode 100644 index 000000000..d4eff2031 --- /dev/null +++ b/newsfragments/single-color-image-rating.change @@ -0,0 +1 @@ +Rate an image of a single color as ``0`` rather than raising an uncaught ``ZeroDivisionError``. diff --git a/spelling_private_dict.txt b/spelling_private_dict.txt index f96162f5c..ef4380bc2 100644 --- a/spelling_private_dict.txt +++ b/spelling_private_dict.txt @@ -39,6 +39,7 @@ exc filename foo formdata +fp github greyscale gzip diff --git a/src/mock_vws/_image_opening.py b/src/mock_vws/_image_opening.py new file mode 100644 index 000000000..a2449b45c --- /dev/null +++ b/src/mock_vws/_image_opening.py @@ -0,0 +1,47 @@ +"""Open images without Pillow's decompression bomb protection.""" + +import contextlib +import threading +from collections.abc import Generator +from typing import IO + +from PIL import Image + +# ``Image.MAX_IMAGE_PIXELS`` is a module level setting, so it is changed for +# as short a time as possible, and only by one thread at a time. +_MAX_IMAGE_PIXELS_LOCK = threading.Lock() + + +# This is not decorated with ``@beartype`` because beartype does not accept +# an ``io.BytesIO`` for an ``IO[bytes]`` parameter, and that is what most +# callers give. +@contextlib.contextmanager +def open_image(*, fp: IO[bytes]) -> Generator[Image.Image]: + """Open an image however many pixels it has. + + Pillow raises :class:`PIL.Image.DecompressionBombError` when opening an + image with more than twice ``Image.MAX_IMAGE_PIXELS`` pixels, and a small + file can decode to many more pixels than that. + Real Vuforia returns a response for such an image rather than failing to + respond, so the mock must be able to open one. + + Pillow checks the pixel count when the image is opened, not when it is + decoded, so ``Image.MAX_IMAGE_PIXELS`` is restored before the image is + used. + + Args: + fp: A file object with the content of the image. + + Yields: + The opened image. + """ + with _MAX_IMAGE_PIXELS_LOCK: + original_max_image_pixels = Image.MAX_IMAGE_PIXELS + Image.MAX_IMAGE_PIXELS = None + try: + image = Image.open(fp=fp) + finally: + Image.MAX_IMAGE_PIXELS = original_max_image_pixels + + with image: + yield image diff --git a/src/mock_vws/_query_validators/image_validators.py b/src/mock_vws/_query_validators/image_validators.py index 8c0494c7e..aa12caa94 100644 --- a/src/mock_vws/_query_validators/image_validators.py +++ b/src/mock_vws/_query_validators/image_validators.py @@ -6,10 +6,10 @@ from email.message import EmailMessage from beartype import beartype -from PIL import Image from werkzeug.datastructures import FileStorage, MultiDict from werkzeug.formparser import MultiPartParser +from mock_vws._image_opening import open_image from mock_vws._query_validators.exceptions import ( BadImageError, ImageNotGivenError, @@ -130,7 +130,7 @@ def validate_image_dimensions( image_part = files["image"] image_value = image_part.stream.read() image_file = io.BytesIO(initial_bytes=image_value) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: max_width = 30000 max_height = 30000 if pil_image.height <= max_height and pil_image.width <= max_width: @@ -160,7 +160,7 @@ def validate_image_format( request_body=request_body, ) image_part = files["image"] - with Image.open(fp=image_part.stream) as pil_image: + with open_image(fp=image_part.stream) as pil_image: if pil_image.format in {"PNG", "JPEG"}: return @@ -190,7 +190,7 @@ def validate_image_is_image( image_file = files["image"].stream try: - with Image.open(fp=image_file) as _: + with open_image(fp=image_file) as _: pass except OSError as exc: _LOGGER.warning(msg="The image is not an image file.") diff --git a/src/mock_vws/_services_validators/__init__.py b/src/mock_vws/_services_validators/__init__.py index d08c1d80b..bf8824e13 100644 --- a/src/mock_vws/_services_validators/__init__.py +++ b/src/mock_vws/_services_validators/__init__.py @@ -32,6 +32,7 @@ validate_image_format, validate_image_integrity, validate_image_is_image, + validate_image_pixel_count, validate_image_size, ) from .json_validators import validate_body_given, validate_json @@ -163,6 +164,7 @@ def run_services_validators( validate_image_format(request_body=request_body) validate_image_color_space(request_body=request_body) validate_image_size(request_body=request_body) + validate_image_pixel_count(request_body=request_body) validate_image_integrity(request_body=request_body) validate_name_type(request_body=request_body) diff --git a/src/mock_vws/_services_validators/image_validators.py b/src/mock_vws/_services_validators/image_validators.py index e5413b7f8..fadf0fea0 100644 --- a/src/mock_vws/_services_validators/image_validators.py +++ b/src/mock_vws/_services_validators/image_validators.py @@ -7,9 +7,9 @@ from http import HTTPStatus from beartype import beartype -from PIL import Image from mock_vws._base64_decoding import decode_base64 +from mock_vws._image_opening import open_image from mock_vws._services_validators.exceptions import ( BadImageError, FailError, @@ -40,7 +40,7 @@ def validate_image_integrity(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: try: pil_image.verify() except SyntaxError as exc: @@ -69,7 +69,7 @@ def validate_image_format(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: if pil_image.format in {"PNG", "JPEG"}: return @@ -99,7 +99,7 @@ def validate_image_color_space(*, request_body: bytes) -> None: decoded = decode_base64(encoded_data=image) image_file = io.BytesIO(initial_bytes=decoded) - with Image.open(fp=image_file) as pil_image: + with open_image(fp=image_file) as pil_image: if pil_image.mode in {"L", "RGB"}: return @@ -139,6 +139,44 @@ def validate_image_size(*, request_body: bytes) -> None: raise ImageTooLargeError +@beartype +def validate_image_pixel_count(*, request_body: bytes) -> None: + """Validate the number of pixels of the image given to a VWS endpoint. + + A small file can decode to a very large number of pixels, so this is not + covered by the file size limit. + + Args: + request_body: The body of the request. + + Raises: + ImageTooLargeError: The image is given and it has more than the + maximum number of pixels. + """ + if not request_body: + return + + request_text = request_body.decode() + image = json.loads(s=request_text).get("image") + + if image is None: + return + + decoded = decode_base64(encoded_data=image) + image_file = io.BytesIO(initial_bytes=decoded) + + # This limit is not documented. + # It was found by binary search against a real database, and it holds + # whatever the image's aspect ratio and color space are. + max_allowed_pixels = 37_748_736 + with open_image(fp=image_file) as pil_image: + if pil_image.width * pil_image.height <= max_allowed_pixels: + return + + _LOGGER.warning(msg="The image has too many pixels.") + raise ImageTooLargeError + + @beartype def validate_image_is_image(*, request_body: bytes) -> None: """Validate that the given image data is actually an image file. @@ -162,9 +200,10 @@ def validate_image_is_image(*, request_body: bytes) -> None: image_file = io.BytesIO(initial_bytes=decoded) try: - with Image.open(fp=image_file) as _: + with open_image(fp=image_file) as _: pass except OSError as exc: + _LOGGER.warning(msg="The image is not an image file.") raise BadImageError from exc diff --git a/src/mock_vws/image_matchers.py b/src/mock_vws/image_matchers.py index be6b76941..aa4b00b99 100644 --- a/src/mock_vws/image_matchers.py +++ b/src/mock_vws/image_matchers.py @@ -7,7 +7,8 @@ import cv2 import numpy as np from beartype import beartype -from PIL import Image + +from mock_vws._image_opening import open_image @runtime_checkable @@ -69,8 +70,8 @@ def __call__( first_image_file = io.BytesIO(initial_bytes=first_image_content) second_image_file = io.BytesIO(initial_bytes=second_image_content) with ( - Image.open(fp=first_image_file) as first_image, - Image.open(fp=second_image_file) as second_image, + open_image(fp=first_image_file) as first_image, + open_image(fp=second_image_file) as second_image, ): # Images must be the same size, and they must be larger than the # default SSIM window size of 11x11. diff --git a/src/mock_vws/target.py b/src/mock_vws/target.py index 557c0d2be..df3217b6b 100644 --- a/src/mock_vws/target.py +++ b/src/mock_vws/target.py @@ -10,9 +10,10 @@ from zoneinfo import ZoneInfo from beartype import BeartypeConf, beartype -from PIL import Image, ImageStat +from PIL import ImageStat from mock_vws._constants import TargetStatuses +from mock_vws._image_opening import open_image from mock_vws.target_raters import ( HardcodedTargetTrackingRater, TargetTrackingRater, @@ -92,7 +93,7 @@ def _post_processing_status(self) -> TargetStatuses: suitable the target is for detection. """ image_file = io.BytesIO(initial_bytes=self.image_value) - with Image.open(fp=image_file) as image: + with open_image(fp=image_file) as image: image_stat = ImageStat.Stat(image_or_list=image) average_std_dev = statistics.mean(data=image_stat.stddev) diff --git a/src/mock_vws/target_raters.py b/src/mock_vws/target_raters.py index 8627b4307..1d4393915 100644 --- a/src/mock_vws/target_raters.py +++ b/src/mock_vws/target_raters.py @@ -8,9 +8,10 @@ from typing import Protocol, runtime_checkable from beartype import beartype -from PIL import Image from pyteenybrisque import score +from mock_vws._image_opening import open_image + @functools.cache @beartype @@ -25,10 +26,15 @@ def _get_brisque_target_tracking_rating(*, image_content: bytes) -> int: image_content: A target's image's content. """ image_file = io.BytesIO(initial_bytes=image_content) - with Image.open(fp=image_file) as image, warnings.catch_warnings(): + with open_image(fp=image_file) as image, warnings.catch_warnings(): # Uniform images produce a zero-variance warning and non-finite score. warnings.simplefilter(action="ignore", category=RuntimeWarning) - brisque_score = score(image=image) + try: + brisque_score = score(image=image) + except ZeroDivisionError: + # An image of a single color divides by zero rather than giving a + # non-finite score. + return 0 if not math.isfinite(brisque_score): return 0 diff --git a/tests/mock_vws/test_add_target.py b/tests/mock_vws/test_add_target.py index b22ecfd56..02b848654 100644 --- a/tests/mock_vws/test_add_target.py +++ b/tests/mock_vws/test_add_target.py @@ -25,7 +25,11 @@ from vws.response import Response from mock_vws._constants import ResultCodes -from tests.mock_vws.utils import make_image_file +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, + make_single_color_image_file, +) from tests.mock_vws.utils.assertions import ( assert_vws_failure, assert_vws_response, @@ -501,6 +505,76 @@ def test_corrupted( result_code=ResultCodes.BAD_IMAGE, ) + @staticmethod + def test_decompression_bomb(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned when the given + image has a small file size but a huge number of pixels. + """ + max_bytes = 2.3 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name", + width=1, + image=image_file, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) + + @staticmethod + def test_image_pixel_count_too_large(vws_client: VWS) -> None: + """ + An ``ImageTooLargeError`` result is returned if the image has + more than 37748736 pixels, whatever its file size. + + This limit is not documented. + """ + max_allowed_pixels = 37_748_736 + width = height = 6144 + assert width * height == max_allowed_pixels + + image_not_too_many_pixels = make_single_color_image_file( + width=width, + height=height, + ) + + vws_client.add_target( + name="example_name", + width=1, + image=image_not_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + image_too_many_pixels = make_single_color_image_file( + width=width + 1, + height=height, + ) + + with pytest.raises(expected_exception=ImageTooLargeError) as exc: + vws_client.add_target( + name="example_name_2", + width=1, + image=image_too_many_pixels, + application_metadata=None, + active_flag=True, + ) + + assert_vws_failure( + response=exc.value.response, + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + result_code=ResultCodes.IMAGE_TOO_LARGE, + ) + @staticmethod def test_image_file_size_too_large(vws_client: VWS) -> None: """ diff --git a/tests/mock_vws/test_query.py b/tests/mock_vws/test_query.py index 95eafab0c..476a46c57 100644 --- a/tests/mock_vws/test_query.py +++ b/tests/mock_vws/test_query.py @@ -39,7 +39,10 @@ from vws_auth_tools import authorization_header, rfc_1123_date from mock_vws.database import CloudDatabase -from tests.mock_vws.utils import make_image_file +from tests.mock_vws.utils import ( + make_decompression_bomb_image_file, + make_image_file, +) from tests.mock_vws.utils.assertions import ( assert_query_success, assert_valid_transaction_id, @@ -1680,6 +1683,24 @@ def test_max_pixels(cloud_reco_client: CloudRecoService) -> None: result = cloud_reco_client.query(image=png_not_too_wide) assert result == [] + @staticmethod + def test_small_file_many_pixels( + cloud_reco_client: CloudRecoService, + ) -> None: + """ + No error is returned for an image with a small file size and a + huge number of pixels. + + Unlike ``POST /targets``, the Query API has no limit on the + number of pixels, only on the width and the height. + """ + max_bytes = 2 * 1024 * 1024 + image_file = make_decompression_bomb_image_file() + assert len(image_file.getvalue()) < max_bytes + + result = cloud_reco_client.query(image=image_file) + assert result == [] + @pytest.mark.usefixtures("verify_mock_vuforia") class TestImageFormats: diff --git a/tests/mock_vws/utils/__init__.py b/tests/mock_vws/utils/__init__.py index 5f48d0664..296692ecd 100644 --- a/tests/mock_vws/utils/__init__.py +++ b/tests/mock_vws/utils/__init__.py @@ -120,3 +120,42 @@ def make_image_file( image.save(fp=image_buffer, format=file_format) image_buffer.seek(0) return image_buffer + + +@beartype +def make_single_color_image_file(*, width: int, height: int) -> io.BytesIO: + """Return a greyscale PNG file of one color. + + A single color image compresses to a tiny file whatever its dimensions, so + this is a way to make an image with many pixels but a small file size. + + Args: + width: The width, in pixels, of the image. + height: The height, in pixels, of the image. + + Returns: + A greyscale PNG file of one color. + """ + image_buffer = io.BytesIO() + image = Image.new(mode="L", size=(width, height)) + image.save(fp=image_buffer, format="PNG") + image_buffer.seek(0) + return image_buffer + + +@beartype +def make_decompression_bomb_image_file() -> io.BytesIO: + """Return a PNG file which is tiny on disk but huge when decoded. + + The dimensions are within the maximum width and height accepted by the + Query API, and the file is well within the maximum file size, but the + pixel count is above the point at which Pillow refuses to open an image. + + Returns: + A PNG file which is a decompression bomb. + """ + # Pillow raises ``Image.DecompressionBombError`` for images with more than + # twice ``Image.MAX_IMAGE_PIXELS`` pixels, which is 178956970 pixels by + # default. + width = height = 15_000 + return make_single_color_image_file(width=width, height=height)