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).
This commit is contained in:
Niko1221 committed 2026-11-04 10:34:11 +01:00
1 parent a3a10b90af
commit 8181522f15
10 files changed
+106 -13

No files matched your search

@@ -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 \
@@ -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 \
@@ -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 \
@@ -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 \
+4 -1
View File
@@ -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
+8 -1
View File
@@ -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
+3 -3
View File
@@ -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)
+10
View File
@@ -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)
+62
View File
@@ -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()
+11
View File
@@ -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:"):