From 716bbd28644ea78beb96c51060a178ca0405fc2e Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 28 Sep 2026 16:55:33 +0300 Subject: [PATCH 1/2] ImageFile: refuse mmaps that are zero-size or partial --- Tests/test_imagefile.py | 27 +++++++++++++++++++++++++++ src/PIL/ImageFile.py | 2 ++ 2 files changed, 29 insertions(+) diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 5e7add81c72..ab92b8014ff 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -252,6 +252,33 @@ def test_broken_datastream_without_errors( monkeypatch.setattr(ImageFile, "LOAD_TRUNCATED_IMAGES", True) im.load() + def test_mmap_partial_tile(self) -> None: + """ + Test that partial tiles of a file won't be mmapped. + """ + extent = (0, 0, 128, 64) + + with Image.open("Tests/images/hopper_8bit.pgm") as im: + im.load() + # Test the premise: this file is regularly loaded with mmap + assert im.map is not None + expected = im.crop(extent) + + with Image.open("Tests/images/hopper_8bit.pgm") as im: + im.tile = [im.tile[0]._replace(extents=extent)] + im.load() + + assert im.map is None + assert_image_equal(im.crop(extent), expected) + + @pytest.mark.parametrize("size", ((0, 128), (128, 0))) + def test_mmap_zero_size(self, size: tuple[int, int]) -> None: + with Image.open("Tests/images/hopper_8bit.pgm") as im: + im._size = size + im.tile = [im.tile[0]._replace(extents=(0, 0, *size))] + with pytest.raises(ValueError, match="tile cannot extend outside image"): + im.load() + class MockPyDecoder(ImageFile.PyDecoder): last: MockPyDecoder diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index c0ca038579d..bf8b41cdb22 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -331,6 +331,8 @@ def load(self) -> Image.core.PixelAccess | None: args = (args, 0, 1) if ( decoder_name == "raw" + and extents == (0, 0, *self.size) + and min(self.size) > 0 and isinstance(args, tuple) and len(args) >= 3 and args[0] == self.mode From 535ab06e79505f0a8e1e949bf3ef4f6dcc1d78c2 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Mon, 28 Sep 2026 16:56:29 +0300 Subject: [PATCH 2/2] TIFF: refuse zero-size continuation pages --- Tests/images/zero_height_frame.tif | Bin 0 -> 300 bytes Tests/images/zero_width_frame.tif | Bin 0 -> 300 bytes Tests/test_file_tiff.py | 12 ++++++++++++ docs/releasenotes/13.0.0.rst | 8 ++++++++ src/PIL/TiffImagePlugin.py | 3 +++ 5 files changed, 23 insertions(+) create mode 100644 Tests/images/zero_height_frame.tif create mode 100644 Tests/images/zero_width_frame.tif diff --git a/Tests/images/zero_height_frame.tif b/Tests/images/zero_height_frame.tif new file mode 100644 index 0000000000000000000000000000000000000000..0ee428eac62f7d95bc88fd44b4a2e09219fbeee3 GIT binary patch literal 300 zcmebD)MD^p00KrPW)@a9b`DN1ZXRAfegQ!tVG&U=aS2H&X&G5Lc?Cr!WffI5bq!4| zZ5>@beFH-yV-r&|a|=r=Ya3fTdrqJUEDVf5gE&BjA+eblnc?EhP&P;%8xmU(NxdkN lxEPYSIF#)G)Vm9a5jL^{c`zG+Oa^M$2y!9JMv$2x008*93s(RD literal 0 HcmV?d00001 diff --git a/Tests/images/zero_width_frame.tif b/Tests/images/zero_width_frame.tif new file mode 100644 index 0000000000000000000000000000000000000000..ba89152ac770a9134b72921cd492d23aa132e074 GIT binary patch literal 300 zcmebD)MD^p00KrPW)@a9b`DN1ZXRAfegQ!tVG&U=aS2H&X&G5Lc?Cr!WffI5bq!4| zZ5>@beFH-yV-r&|a|=r=Ya3fTdrqJUEDVf5gE&BjA+eblnc?EhP&P;%8xmU(NxdkN kxEPYSIF#)G)Vm9a5jFxja2r{HY--pDG6!ZO$b1k00Qk-eTL1t6 literal 0 HcmV?d00001 diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 523db1d0a29..3be8f758f47 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -750,6 +750,18 @@ def test_invalid_tiled_dimensions(self) -> None: with pytest.raises(ValueError): Image.open(b) + @pytest.mark.parametrize( + "test_file", + ( + "Tests/images/zero_width_frame.tif", + "Tests/images/zero_height_frame.tif", + ), + ) + def test_zero_size_frame(self, test_file: str) -> None: + with Image.open(test_file) as im: + with pytest.raises(SyntaxError, match="Invalid dimensions"): + im.seek(1) + @pytest.mark.parametrize("mode", ("P", "PA")) def test_palette(self, mode: str, tmp_path: Path) -> None: outfile = tmp_path / "temp.tif" diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index b9906b7f19d..5468fcff63b 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -228,3 +228,11 @@ position or contents. :py:meth:`~PIL.Image.Image.tobytes` also now explicitly cleans up its encoder on both success and failure, rather than relying on object destruction. + +Rejected zero-size TIFF frames +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Seeking to a TIFF frame with a zero width or height now raises a ``SyntaxError``, +as opening such a frame as the first frame already did. Previously, when the image +was opened from a filename and was uncompressed, the frame could be loaded as an +image with no pixels. diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index dd7822b3d27..70bc30e3f49 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1469,6 +1469,9 @@ def _setup(self) -> None: if not isinstance(xsize, int) or not isinstance(ysize, int): msg = "Invalid dimensions" raise ValueError(msg) + if xsize <= 0 or ysize <= 0: + msg = f"Invalid dimensions {xsize} x {ysize}" + raise SyntaxError(msg) self._tile_size = xsize, ysize orientation = self.tag_v2.get(ExifTags.Base.Orientation) if orientation in (5, 6, 7, 8):