From 8181522f152785f283aba74f4bef4ad0889cada5 Mon Sep 17 00:00:00 2001 From: Niko1221 Date: Wed, 4 Nov 2026 10:34:11 +0100 Subject: [PATCH] Pillow 13.0.0 on Python 3.11+ (GHSA-78g4-xqwv-q3g8, OpenJPEG heap overflow) and a decoder format list (#4220) requirements.txt and the four hash-locked terminal locks (windows, linux x86_64, linux aarch64, macOS arm64) move to 13.0.0; Python 3.10 stays on 12.3.0. Image.open() in the vision path, the EXIF probe and the DeepSeek picture reader now only opens JPEG, PNG, GIF, BMP, WebP, TIFF and AVIF, so a JPEG 2000 file never reaches OpenJPEG on any Pillow version. Approach from PR #4220 (version split by Python range). --- .../locks/runtime-darwin-arm64-cp312.txt | 4 +- .../locks/runtime-linux-aarch64-cp312.txt | 4 +- .../locks/runtime-linux-x86_64-cp312.txt | 4 +- .../locks/runtime-windows-x86_64-cp312.txt | 4 +- requirements.txt | 5 +- serve/ds_pictures.py | 9 ++- serve/server.py | 6 +- serve/test_ds_pictures.py | 10 +++ serve/test_exif_orientation.py | 62 +++++++++++++++++++ tests/terminal/test_packaging_pins.py | 11 ++++ 10 files changed, 106 insertions(+), 13 deletions(-) diff --git a/packaging/locks/runtime-darwin-arm64-cp312.txt b/packaging/locks/runtime-darwin-arm64-cp312.txt index a60b44822..01d65e3fe 100644 --- a/packaging/locks/runtime-darwin-arm64-cp312.txt +++ b/packaging/locks/runtime-darwin-arm64-cp312.txt @@ -22,8 +22,8 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 numpy==2.5.3 \ --hash=sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c -pillow==12.3.0 \ - --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 +pillow==13.0.0 \ + --hash=sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52 platformdirs==4.13.0 \ --hash=sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1 psutil==7.2.2 \ diff --git a/packaging/locks/runtime-linux-aarch64-cp312.txt b/packaging/locks/runtime-linux-aarch64-cp312.txt index f5d9f62e3..c09927726 100644 --- a/packaging/locks/runtime-linux-aarch64-cp312.txt +++ b/packaging/locks/runtime-linux-aarch64-cp312.txt @@ -22,8 +22,8 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 numpy==2.5.3 \ --hash=sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58 -pillow==12.3.0 \ - --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 +pillow==13.0.0 \ + --hash=sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d platformdirs==4.13.6 \ --hash=sha256:21fc198fb09f3e78cadcdfd8a6ae4fd65cadc64a3cba78b694321c2996114a3e psutil==7.2.2 \ diff --git a/packaging/locks/runtime-linux-x86_64-cp312.txt b/packaging/locks/runtime-linux-x86_64-cp312.txt index d1fe6e648..87346c1d0 100644 --- a/packaging/locks/runtime-linux-x86_64-cp312.txt +++ b/packaging/locks/runtime-linux-x86_64-cp312.txt @@ -22,8 +22,8 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 numpy==2.5.3 \ --hash=sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3 -pillow==12.3.0 \ - --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 +pillow==13.0.0 \ + --hash=sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca platformdirs==4.13.0 \ --hash=sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1 psutil==7.2.2 \ diff --git a/packaging/locks/runtime-windows-x86_64-cp312.txt b/packaging/locks/runtime-windows-x86_64-cp312.txt index e3870fc95..8c3713961 100644 --- a/packaging/locks/runtime-windows-x86_64-cp312.txt +++ b/packaging/locks/runtime-windows-x86_64-cp312.txt @@ -24,8 +24,8 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 numpy==2.5.3 \ --hash=sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def -pillow==12.3.0 \ - --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 +pillow==13.0.0 \ + --hash=sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b platformdirs==4.13.0 \ --hash=sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1 psutil==7.2.2 \ diff --git a/requirements.txt b/requirements.txt index 903773a12..19b446ff9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,10 @@ regex==2026.9.10 pyyaml==6.0.3 tqdm==4.70.1 requests==2.34.2 -pillow==12.3.0 +# 13.0.0 carries the fix for GHSA-78g4-xqwv-q3g8 (OpenJPEG heap overflow reachable from Image.open().load(), 11.1.0 to 12.3.0). +# Pillow 13 needs Python 3.11; Python 3.10 stays on 12.3.0 and relies on serve/ds_pictures.py PICTURE_FORMATS (no JPEG 2000 decoder). +pillow==13.0.0; python_version >= "3.11" +pillow==12.3.0; python_version < "3.11" psutil==7.2.2 # their dependencies diff --git a/serve/ds_pictures.py b/serve/ds_pictures.py index 236a61dcb..89cbc4214 100644 --- a/serve/ds_pictures.py +++ b/serve/ds_pictures.py @@ -18,6 +18,13 @@ import struct import tempfile from pathlib import Path +#: The picture formats Pillow is allowed to open (docs/DETAILS.md: JPEG, PNG, WebP, GIF, BMP, TIFF, AVIF; a phone's MPO file +#: is opened by the JPEG entry). Pillow picks the decoder from the file's first bytes, never from its name or the client's +#: Content-Type, so without this list a crafted JPEG 2000 (GHSA-78g4-xqwv-q3g8: OpenJPEG heap overflow in Pillow +#: 11.1.0 to 12.3.0) or any other rarely used decoder was reachable from a picture request. Pillow 13 has the fix; +#: Python 3.10 stays on 12.3.0, and this list keeps that install out of the decoder too. +PICTURE_FORMATS = ("JPEG", "PNG", "GIF", "BMP", "WEBP", "TIFF", "AVIF") + PATCH = 14 MERGE = 3 MAX_TOKENS = 1024 @@ -138,7 +145,7 @@ class DsPictures: except ImportError: raise ValueError("DeepSeek reads pictures with Pillow, which is not installed here (python -m pip install pillow)") from None try: - im = Image.open(io.BytesIO(data)) + im = Image.open(io.BytesIO(data), formats=PICTURE_FORMATS) im.load() im = ImageOps.exif_transpose(im) except Exception as e: # noqa: BLE001 - any decoder failure is the picture's diff --git a/serve/server.py b/serve/server.py index 7399e9373..41f796d34 100644 --- a/serve/server.py +++ b/serve/server.py @@ -72,7 +72,7 @@ from strata_control import tls # noqa: E402 tls.install() # #3722: image URLs and tool servers over HTTPS are checked against the operating system's certificates from strata_control import hipblaslt_tuning # noqa: E402 from strata_control import vision_reader # noqa: E402 -from serve.ds_pictures import DsPictures # noqa: E402 +from serve.ds_pictures import PICTURE_FORMATS, DsPictures # noqa: E402 from strata_control import draft_layer # noqa: E402 from strata_control import drafting # noqa: E402 from strata_control import vram_fit # noqa: E402 @@ -3189,7 +3189,7 @@ class Vision: try: import io from PIL import Image - with Image.open(io.BytesIO(data)) as im: + with Image.open(io.BytesIO(data), formats=PICTURE_FORMATS) as im: return im.getexif().get(0x0112, 1) not in (None, 0, 1) except Exception: # noqa: BLE001 - any failure leaves the image as it was sent return False @@ -3209,7 +3209,7 @@ class Vision: raise ValueError("this image format needs Pillow (python -m pip install pillow); JPEG, PNG, BMP and " "GIF work without it") from None try: - im = Image.open(io.BytesIO(data)) + im = Image.open(io.BytesIO(data), formats=PICTURE_FORMATS) im.load() from PIL import ImageOps im = ImageOps.exif_transpose(im) # the camera's orientation tag, applied (#1229) diff --git a/serve/test_ds_pictures.py b/serve/test_ds_pictures.py index a997d5c5e..c2e57d95f 100644 --- a/serve/test_ds_pictures.py +++ b/serve/test_ds_pictures.py @@ -138,6 +138,16 @@ class Build(unittest.TestCase): with self.assertRaises(ValueError): self.p.prepare(b"not an image") + def test_a_jpeg_2000_picture_is_refused_before_it_is_decoded(self): + """#4220: JPEG 2000 is outside D.PICTURE_FORMATS (GHSA-78g4-xqwv-q3g8).""" + buf = io.BytesIO() + try: + Image.new("RGB", (16, 16)).save(buf, format="JPEG2000") + except (KeyError, OSError, ValueError): + self.skipTest("this Pillow cannot write JPEG 2000") + with self.assertRaises(ValueError): + self.p.prepare(buf.getvalue()) + def test_the_engine_limit_holds_for_a_huge_picture(self): w, h, rgb = self.p.prepare(png(3000, 2000)) self.assertLessEqual(D.span_length(w, h), 1024) diff --git a/serve/test_exif_orientation.py b/serve/test_exif_orientation.py index 5eafa0ae5..0bb361b93 100644 --- a/serve/test_exif_orientation.py +++ b/serve/test_exif_orientation.py @@ -1,6 +1,7 @@ """#1229: a phone JPEG stored turned (EXIF Orientation 6 or 8) is turned upright before the vision encoder reads it.""" import io import unittest +from unittest import mock from serve.server import Vision @@ -43,5 +44,66 @@ class ExifOrientation(unittest.TestCase): self.assertEqual(Vision.normalize(data), data) +@unittest.skipIf(Image is None, "Pillow is not installed") +class DecoderAllowlist(unittest.TestCase): + """GHSA-78g4-xqwv-q3g8 (#4220): a crafted JPEG 2000 reached OpenJPEG's heap overflow through Image.open().load(), because + Pillow picks the decoder by the file's first bytes. Only the formats the picture reader documents are opened.""" + + @staticmethod + def sample(fmt, **kw) -> bytes: + out = io.BytesIO() + Image.new("RGB", (16, 8), (10, 120, 200)).save(out, format=fmt, **kw) + return out.getvalue() + + def test_a_jpeg_2000_picture_is_refused_before_any_decoder_runs(self): + try: + data = self.sample("JPEG2000") + except (KeyError, OSError, ValueError): + self.skipTest("this Pillow cannot write JPEG 2000") + with mock.patch.object(Image.Image, "load", side_effect=AssertionError("the JPEG 2000 decoder ran")): + with self.assertRaises(ValueError) as caught: + Vision.normalize(data) + self.assertIn("could not be read", str(caught.exception)) + + def test_other_formats_outside_the_list_are_refused_too(self): + for fmt in ("PPM", "ICO", "TGA"): + with self.subTest(fmt): + with self.assertRaises(ValueError): + Vision.normalize(self.sample(fmt)) + + def test_the_documented_formats_still_become_a_png(self): + for fmt, kw in (("WEBP", {}), ("TIFF", {}), ("GIF", {}), ("PNG", {})): + with self.subTest(fmt): + data = self.sample(fmt, **kw) + out = Vision.normalize(data) + self.assertEqual(Image.open(io.BytesIO(out)).size, (16, 8)) + + def test_a_phone_mpo_file_is_still_read_and_turned(self): + a, b = Image.new("RGB", (40, 20), (200, 30, 30)), Image.new("RGB", (40, 20), (30, 200, 30)) + exif = Image.Exif() + exif[0x0112] = 6 + out = io.BytesIO() + a.save(out, format="MPO", save_all=True, append_images=[b], exif=exif) + self.assertEqual(Image.open(io.BytesIO(out.getvalue())).format, "MPO") + self.assertEqual(Image.open(io.BytesIO(Vision.normalize(out.getvalue()))).size, (20, 40)) + + def test_the_exif_probe_and_both_decoders_pass_the_list(self): + seen = [] + real = Image.open + + def spy(*a, **kw): + seen.append(kw.get("formats")) + return real(*a, **kw) + + data = self.sample("WEBP") + with mock.patch.object(Image, "open", spy): + Vision.normalize(data) + Vision._exif_rotated(jpeg((8, 8), 6)) + from serve.ds_pictures import DsPictures + DsPictures.decode(data) + self.assertEqual(len(seen), 3) + self.assertTrue(all(f and "JPEG2000" not in f and "JPEG" in f for f in seen), seen) + + if __name__ == "__main__": unittest.main() diff --git a/tests/terminal/test_packaging_pins.py b/tests/terminal/test_packaging_pins.py index ef9c91133..10e626a62 100644 --- a/tests/terminal/test_packaging_pins.py +++ b/tests/terminal/test_packaging_pins.py @@ -92,6 +92,17 @@ class WheelLocks(unittest.TestCase): self.assertNotIn("cmake", names) # build tools never ship in the bundle self.assertNotIn("ninja", names) + def test_pillow_is_the_patched_release_everywhere_it_is_pinned(self): + """#4220: 12.3.0's bundled OpenJPEG has a heap overflow (GHSA-78g4-xqwv-q3g8, fixed in 13.0.0). Pillow 13 needs Python 3.11, so + requirements.txt keeps 12.3.0 for 3.10 only; every hash-locked bundle (Python 3.12) carries 13.0.0 or later.""" + lines = [l.split(" #")[0].strip() for l in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()] + pillow = [l for l in lines if l.lower().startswith("pillow")] + self.assertEqual(sorted(pillow), ['pillow==12.3.0; python_version < "3.11"', 'pillow==13.0.0; python_version >= "3.11"']) + for lock in self.locks(): + with self.subTest(lock.name): + version = pt.lock_names(lock)["pillow"] + self.assertGreaterEqual(tuple(int(x) for x in version.split(".")), (13, 0, 0), version) + def test_the_build_installs_with_hash_checking_and_no_resolution(self): text = (ROOT / "packaging" / "package_terminal.py").read_text(encoding="utf-8") for flag in ("--require-hashes", "--no-deps", "--only-binary=:all:"):