From d67ee8265a94db52833ac1bc44eae953110f0c3e Mon Sep 17 00:00:00 2001 From: Nathan Keys Date: Fri, 13 Nov 2026 13:22:28 +0000 Subject: [PATCH 1/3] Return the skipped byte count from the JPEG 2000 decoder skip callback --- Tests/test_file_jpeg2k.py | 58 +++++++++++++++++++++++++++++++++++ src/decode.c | 3 ++ src/libImaging/Jpeg2KDecode.c | 13 +++++--- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 88b0191c82d..59c2d6dcd38 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -255,6 +255,64 @@ def test_oversized_box_length(tbox: bytes) -> None: Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) +def _insert_xml_box(size: int) -> bytes: + # Returns a valid image with an xml box of the given size placed before + # the codestream box, which OpenJPEG skips + with open("Tests/images/test-card-lossless.jp2", "rb") as fp: + data = fp.read() + offset = 0 + while data[offset + 4 : offset + 8] != b"jp2c": + offset += _binary.i32be(data, offset) + xml = b"" + b" " * size + b"" + box = _binary.o32be(8 + len(xml)) + b"xml " + xml + return data[:offset] + box + data[offset:] + + +def test_skipped_box_benign_control() -> None: + # A box smaller than the stream buffer does not reach the skip callback + boxed = _insert_xml_box(1024) + with Image.open(BytesIO(boxed)) as im: + assert im.size == (640, 480) + assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") + + +def test_large_skipped_box(tmp_path: Path) -> None: + # A box larger than the stream buffer reaches the skip callback + boxed = _insert_xml_box(2 * 1024 * 1024) + + outfile = tmp_path / "temp.jp2" + outfile.write_bytes(boxed) + with Image.open(outfile) as im: + assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") + + with Image.open(BytesIO(boxed)) as im: + assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") + + +def test_large_skipped_box_truncated() -> None: + # If the file ends within the box, it is still rejected + boxed = _insert_xml_box(2 * 1024 * 1024) + with Image.open(BytesIO(boxed[: len(boxed) // 2])) as im: + with pytest.raises(OSError): + im.load() + + +def test_large_skipped_box_seek_error() -> None: + # An error from seeking over a box is reported directly without leaving + # an unhandled exception set on decoder return + class FailingSeek(BytesIO): + def seek(self, offset: int, whence: int = 0) -> int: + if whence == 1 and offset > 512 * 1024: + msg = "seek refused" + raise OSError(msg) + return super().seek(offset, whence) + + boxed = _insert_xml_box(2 * 1024 * 1024) + with Image.open(FailingSeek(boxed)) as im: + with pytest.raises(OSError, match="seek refused"): + im.load() + + def test_layers_type(card: ImageFile.ImageFile, tmp_path: Path) -> None: outfile = tmp_path / "temp_layers.jp2" for quality_layers in [[100, 50, 10], (100, 50, 10), None]: diff --git a/src/decode.c b/src/decode.c index 4f96fd36491..e78b7a22984 100644 --- a/src/decode.c +++ b/src/decode.c @@ -140,6 +140,9 @@ _decode(ImagingDecoderObject *decoder, PyObject *args) { } PyBuffer_Release(&buffer); + if (PyErr_Occurred()) { + return NULL; + } return Py_BuildValue("ii", status, decoder->state.errcode); } diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index fb0e1fc6a00..0113e3397ed 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -46,6 +46,10 @@ static OPJ_SIZE_T j2k_read(void *p_buffer, OPJ_SIZE_T p_nb_bytes, void *p_user_data) { ImagingCodecState state = (ImagingCodecState)p_user_data; + if (PyErr_Occurred()) { + return (OPJ_SIZE_T)-1; + } + size_t len = _imaging_read_pyFd(state->fd, p_buffer, p_nb_bytes); return len ? len : (OPJ_SIZE_T)-1; @@ -53,13 +57,14 @@ j2k_read(void *p_buffer, OPJ_SIZE_T p_nb_bytes, void *p_user_data) { static OPJ_OFF_T j2k_skip(OPJ_OFF_T p_nb_bytes, void *p_user_data) { - off_t pos; ImagingCodecState state = (ImagingCodecState)p_user_data; - _imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR); - pos = _imaging_tell_pyFd(state->fd); + if (PyErr_Occurred() || _imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR) != 0) { + return (OPJ_OFF_T)-1; + } - return pos ? pos : (OPJ_OFF_T)-1; + /* OpenJPEG expects the number of bytes skipped, not the new position */ + return p_nb_bytes; } /* -------------------------------------------------------------------- */ From 5cbb4b0321e9c0a75cdaa7d473d9ffed3cecce56 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 14 Nov 2026 15:50:51 +1100 Subject: [PATCH 2/3] Match existing check in ImagingSgiRleDecode --- Tests/test_file_jpeg2k.py | 53 ++++++----------------------------- src/libImaging/Jpeg2KDecode.c | 3 +- 2 files changed, 10 insertions(+), 46 deletions(-) diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 59c2d6dcd38..ffb5b1de113 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -255,60 +255,25 @@ def test_oversized_box_length(tbox: bytes) -> None: Jpeg2KImagePlugin.Jpeg2KImageFile(BytesIO(data)) -def _insert_xml_box(size: int) -> bytes: - # Returns a valid image with an xml box of the given size placed before - # the codestream box, which OpenJPEG skips +def test_large_skipped_box() -> None: + # A box larger than the stream buffer reaches the skip callback + xml = b" " * 2 * 1024 * 1024 + box = _binary.o32be(8 + len(xml)) + b"xml " + xml with open("Tests/images/test-card-lossless.jp2", "rb") as fp: data = fp.read() - offset = 0 - while data[offset + 4 : offset + 8] != b"jp2c": - offset += _binary.i32be(data, offset) - xml = b"" + b" " * size + b"" - box = _binary.o32be(8 + len(xml)) + b"xml " + xml - return data[:offset] + box + data[offset:] - - -def test_skipped_box_benign_control() -> None: - # A box smaller than the stream buffer does not reach the skip callback - boxed = _insert_xml_box(1024) - with Image.open(BytesIO(boxed)) as im: - assert im.size == (640, 480) - assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") - - -def test_large_skipped_box(tmp_path: Path) -> None: - # A box larger than the stream buffer reaches the skip callback - boxed = _insert_xml_box(2 * 1024 * 1024) + data = data[:326] + box + data[326:] - outfile = tmp_path / "temp.jp2" - outfile.write_bytes(boxed) - with Image.open(outfile) as im: - assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") - - with Image.open(BytesIO(boxed)) as im: + with Image.open(BytesIO(data)) as im: assert_image_equal_tofile(im, "Tests/images/test-card-lossless.jp2") - -def test_large_skipped_box_truncated() -> None: - # If the file ends within the box, it is still rejected - boxed = _insert_xml_box(2 * 1024 * 1024) - with Image.open(BytesIO(boxed[: len(boxed) // 2])) as im: - with pytest.raises(OSError): - im.load() - - -def test_large_skipped_box_seek_error() -> None: - # An error from seeking over a box is reported directly without leaving - # an unhandled exception set on decoder return class FailingSeek(BytesIO): - def seek(self, offset: int, whence: int = 0) -> int: - if whence == 1 and offset > 512 * 1024: + def seek(self, offset: int, whence: int = os.SEEK_SET) -> int: + if whence == os.SEEK_CUR and offset > 512 * 1024: msg = "seek refused" raise OSError(msg) return super().seek(offset, whence) - boxed = _insert_xml_box(2 * 1024 * 1024) - with Image.open(FailingSeek(boxed)) as im: + with Image.open(FailingSeek(data)) as im: with pytest.raises(OSError, match="seek refused"): im.load() diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index 0113e3397ed..fe210c5b63d 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -59,11 +59,10 @@ static OPJ_OFF_T j2k_skip(OPJ_OFF_T p_nb_bytes, void *p_user_data) { ImagingCodecState state = (ImagingCodecState)p_user_data; - if (PyErr_Occurred() || _imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR) != 0) { + if (PyErr_Occurred() || _imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR) == -1) { return (OPJ_OFF_T)-1; } - /* OpenJPEG expects the number of bytes skipped, not the new position */ return p_nb_bytes; } From e3a53eff745f20d81eba21ebd4461ad9c723a676 Mon Sep 17 00:00:00 2001 From: Nathan Keys Date: Sat, 14 Nov 2026 16:12:42 +0000 Subject: [PATCH 3/3] Return the skipped byte count from the JPEG 2000 decoder skip callback Let OpenJPEG handle the error results already returned by the read and skip callbacks. Keep the checked seek and skipped-byte-count return. --- src/libImaging/Jpeg2KDecode.c | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/libImaging/Jpeg2KDecode.c b/src/libImaging/Jpeg2KDecode.c index fe210c5b63d..e069a0b2ce5 100644 --- a/src/libImaging/Jpeg2KDecode.c +++ b/src/libImaging/Jpeg2KDecode.c @@ -46,10 +46,6 @@ static OPJ_SIZE_T j2k_read(void *p_buffer, OPJ_SIZE_T p_nb_bytes, void *p_user_data) { ImagingCodecState state = (ImagingCodecState)p_user_data; - if (PyErr_Occurred()) { - return (OPJ_SIZE_T)-1; - } - size_t len = _imaging_read_pyFd(state->fd, p_buffer, p_nb_bytes); return len ? len : (OPJ_SIZE_T)-1; @@ -59,7 +55,7 @@ static OPJ_OFF_T j2k_skip(OPJ_OFF_T p_nb_bytes, void *p_user_data) { ImagingCodecState state = (ImagingCodecState)p_user_data; - if (PyErr_Occurred() || _imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR) == -1) { + if (_imaging_seek_pyFd(state->fd, p_nb_bytes, SEEK_CUR) == -1) { return (OPJ_OFF_T)-1; }