Commit ff7bf3ce for libheif

commit ff7bf3ce4e63c911d2f769164250bdb653dcb547
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Sep 20 22:46:10 2026 +0200

    Reject a mismatched Cb/Cr plane size (GHSA-j2rv-58fh-w8pw)

    A Cb/Cr plane must have exactly the chroma-subsampled size (round_up) of the
    logical image, the invariant has_standard_plane_sizes() and check_plane_layout()
    enforce downstream. Nothing stopped a caller from building an inconsistent image
    through the public API (heif_image_create() + heif_image_add_plane()) by adding a
    Cb/Cr plane at any other size, and that mismatch is a memory-safety hazard: an
    oversized plane fooled the per-row fill in extend_to_size_with_zero() into an
    unsigned underflow and a ~4 GB out-of-bounds write (GHSA-j2rv-58fh-w8pw), and an
    undersized one caused out-of-bounds reads during RGB conversion (issue #1796).

    Reject the mismatch in add_channel() so the inconsistent image can never be built.
    As defense in depth, extend_to_size_with_zero() also gains a per-plane preflight
    that rejects, before modifying any plane, a target that would shrink any plane so
    a rejected request cannot leave the image partially modified; this backstops the
    GHSA-hqc2-cx5m-g6ff logical-size guard, whose whole-image check does not see an
    individually oversized chroma plane. Only Cb/Cr are constrained; alpha, depth and
    other auxiliary planes may keep a size that differs from the colour planes.

    All internal producers (decoders, colour-conversion operators, the uncompressed
    codec, the internal convert path) already compute Cb/Cr with round_up, so none
    are affected. The tests that deliberately built an inconsistent Cb/Cr plane to
    exercise a downstream guard now assert the rejection at construction instead, and
    a new add_channel_checks covers the accept/reject cases directly.

    This is only reachable by an application calling the public API with an
    out-of-contract image; no HEIF/AVIF file triggers it.

diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index 443082c0..ab9b251c 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -381,6 +381,28 @@ Error HeifPixelImage::add_channel(heif_channel channel, uint32_t width, uint32_t
             "Use an interleaved format with alpha (e.g. heif_chroma_interleaved_RGBA) instead."};
   }

+  // A Cb/Cr plane must have exactly the chroma-subsampled size of the logical image
+  // (round_up), the invariant has_standard_plane_sizes() and check_plane_layout()
+  // enforce elsewhere. Building one at a different size makes the plane's actual
+  // allocation disagree with the size that code derives from the logical image
+  // dimensions, and that mismatch is a memory-safety hazard: an oversized plane
+  // fooled the per-row fill in extend_to_size_with_zero() into an unsigned underflow
+  // and a ~4 GB out-of-bounds write (GHSA-j2rv-58fh-w8pw), an undersized one caused
+  // out-of-bounds reads during RGB conversion (issue #1796). Reject the mismatch at
+  // construction so the inconsistent image cannot be built in the first place. Only
+  // Cb/Cr are constrained; alpha, depth, disparity and other auxiliary planes may
+  // legitimately have a size that differs from the colour planes.
+  if (channel == heif_channel_Cb || channel == heif_channel_Cr) {
+    uint32_t expected_width, expected_height;
+    get_subsampled_size(m_width, m_height, channel, m_chroma, &expected_width, &expected_height);
+
+    if (width != expected_width || height != expected_height) {
+      return {heif_error_Usage_error,
+              heif_suberror_Invalid_parameter_value,
+              "A Cb/Cr plane must have the chroma-subsampled size of the image."};
+    }
+  }
+
   // for backwards compatibility, allow for 24/32 bits for RGB/RGBA interleaved chromas

   if (m_chroma == heif_chroma_interleaved_RGB && bit_depth == 24) {
@@ -673,6 +695,27 @@ Error HeifPixelImage::extend_to_size_with_zero(uint32_t width, uint32_t height,
     return Error::Ok;
   }

+  // Preflight: no plane may shrink. Even when the logical target is not smaller
+  // than the image, get_subsampled_size() can map it to a per-component size that
+  // is smaller than a plane's current size, e.g. a Cb/Cr plane that was added
+  // larger than the target's subsampled chroma extent. The per-row right-edge fill
+  // below would then compute its length as (subsampled_width - old_width), underflow
+  // uint32_t, and write past the end of the plane. Validate every plane before
+  // touching any so a rejected request cannot leave the image partially modified
+  // (GHSA-j2rv-58fh-w8pw, a follow-up to GHSA-hqc2-cx5m-g6ff whose logical-size
+  // guard above does not cover an individually oversized chroma plane).
+  for (const auto& component : m_storage) {
+    uint32_t subsampled_width, subsampled_height;
+    get_subsampled_size(width, height, component.m_channel, m_chroma,
+                        &subsampled_width, &subsampled_height);
+
+    if (subsampled_width < component.m_width || subsampled_height < component.m_height) {
+      return Error{heif_error_Usage_error,
+                   heif_suberror_Invalid_parameter_value,
+                   "Cannot extend an image to a size smaller than an existing plane."};
+    }
+  }
+
   for (auto& component : m_storage) {
     // See extend_padding_to_size(): get_subsampled_size() assumes a non-Cb/Cr
     // component has the full logical image size, so we cannot compute a correct
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 4df9937f..1e15a2ea 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -56,6 +56,7 @@ else()
     add_libheif_test(crop_plane_checks)
     add_libheif_test(extract_area_plane_checks)
     add_libheif_test(extend_to_size_checks)
+    add_libheif_test(add_channel_checks)
     add_libheif_test(jpeg2000)
     add_libheif_test(avc_box)
     add_libheif_test(hevc_sps)
diff --git a/tests/add_channel_checks.cc b/tests/add_channel_checks.cc
new file mode 100644
index 00000000..c38ce4b6
--- /dev/null
+++ b/tests/add_channel_checks.cc
@@ -0,0 +1,114 @@
+/*
+  libheif unit tests
+
+  MIT License
+
+  Copyright (c) 2026 Dirk Farin <dirk.farin@gmail.com>
+
+  Permission is hereby granted, free of charge, to any person obtaining a copy
+  of this software and associated documentation files (the "Software"), to deal
+  in the Software without restriction, including without limitation the rights
+  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+  copies of the Software, and to permit persons to whom the Software is
+  furnished to do so, subject to the following conditions:
+
+  The above copyright notice and this permission notice shall be included in all
+  copies or substantial portions of the Software.
+
+  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+  SOFTWARE.
+*/
+
+#include "image/pixelimage.h"
+#include "catch_amalgamated.hpp"
+
+// Regression tests for the Cb/Cr size check in HeifPixelImage::add_channel().
+//
+// A Cb/Cr plane must have exactly the chroma-subsampled size of the logical image
+// (round_up). Building one at a different size makes the plane's allocation disagree
+// with the size other code derives from the logical dimensions. An oversized plane
+// let extend_to_size_with_zero() underflow its per-row fill length and write ~4 GB
+// past the plane (GHSA-j2rv-58fh-w8pw); an undersized plane caused out-of-bounds
+// reads during RGB conversion (issue #1796). add_channel() now rejects the mismatch
+// at construction. Only Cb/Cr are constrained; alpha/depth/... may differ.
+
+TEST_CASE("add_channel rejects an oversized Cb plane (GHSA-j2rv-58fh-w8pw)") {
+  auto* limits = heif_get_global_security_limits();
+
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+  REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+
+  // The subsampled Cb size of a 12x8 4:2:0 image is 6x4; 32x8 is far too large.
+  Error err = image->add_channel(heif_channel_Cb, 32, 8, 8, limits);
+  REQUIRE(err.error_code == heif_error_Usage_error);
+  REQUIRE(err.sub_error_code == heif_suberror_Invalid_parameter_value);
+}
+
+TEST_CASE("add_channel rejects an undersized (round-down) chroma plane") {
+  auto* limits = heif_get_global_security_limits();
+
+  // Odd dimensions: the round_up subsampled size is 7x5, so a floor-division 6x4
+  // plane (as an out-of-spec producer might compute) must be rejected.
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(13, 9, heif_colorspace_YCbCr, heif_chroma_420);
+  REQUIRE(image->add_channel(heif_channel_Y, 13, 9, 8, limits).error_code == heif_error_Ok);
+
+  REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Usage_error);
+  REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Usage_error);
+}
+
+TEST_CASE("add_channel accepts correctly-subsampled chroma planes") {
+  auto* limits = heif_get_global_security_limits();
+
+  SECTION("4:2:0, even dimensions") {
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+    REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Ok);
+  }
+
+  SECTION("4:2:0, odd dimensions round up") {
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(13, 9, heif_colorspace_YCbCr, heif_chroma_420);
+    REQUIRE(image->add_channel(heif_channel_Y, 13, 9, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cb, 7, 5, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cr, 7, 5, 8, limits).error_code == heif_error_Ok);
+  }
+
+  SECTION("4:2:2 subsamples width only") {
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_422);
+    REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cb, 6, 8, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cr, 6, 8, 8, limits).error_code == heif_error_Ok);
+  }
+
+  SECTION("4:4:4 chroma is full size") {
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_444);
+    REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cb, 12, 8, 8, limits).error_code == heif_error_Ok);
+    REQUIRE(image->add_channel(heif_channel_Cr, 12, 8, 8, limits).error_code == heif_error_Ok);
+  }
+}
+
+TEST_CASE("add_channel does not constrain non-chroma auxiliary planes") {
+  auto* limits = heif_get_global_security_limits();
+
+  // The size rule is Cb/Cr only. An auxiliary plane such as depth may legitimately
+  // have a size unrelated to the colour planes, so add_channel must still accept it.
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(12, 8, heif_colorspace_YCbCr, heif_chroma_420);
+  REQUIRE(image->add_channel(heif_channel_Y, 12, 8, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Cb, 6, 4, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Cr, 6, 4, 8, limits).error_code == heif_error_Ok);
+
+  REQUIRE(image->add_channel(heif_channel_depth, 5, 5, 8, limits).error_code == heif_error_Ok);
+}
diff --git a/tests/plane_layout.cc b/tests/plane_layout.cc
index 1c760c44..883d06ae 100644
--- a/tests/plane_layout.cc
+++ b/tests/plane_layout.cc
@@ -168,14 +168,22 @@ TEST_CASE("check_plane_layout rejects non-canonical layouts")
   }

   SECTION("chroma plane with the wrong size") {
+    // add_channel() now refuses to build a Cb/Cr plane whose size is not the
+    // chroma-subsampled size of the image, so the inconsistent state that
+    // check_plane_layout()'s "Cb plane has size" branch used to catch can no
+    // longer be constructed. Verify the rejection happens at construction.
     auto img = std::make_shared<HeifPixelImage>();
     img->create(W, H, heif_colorspace_YCbCr, heif_chroma_420);
     REQUIRE(!img->add_channel(heif_channel_Y, W, H, 8, nullptr));
-    REQUIRE(!img->add_channel(heif_channel_Cb, W, H, 8, nullptr)); // should be W/2 x H/2
-    REQUIRE(!img->add_channel(heif_channel_Cr, W / 2, H / 2, 8, nullptr));
-    Error err = img->check_plane_layout();
+
+    Error err = img->add_channel(heif_channel_Cb, W, H, 8, nullptr); // should be W/2 x H/2
     REQUIRE(err);
-    CHECK(mentions(err, "Cb plane has size"));
+    CHECK(err.error_code == heif_error_Usage_error);
+
+    // The correctly-sized planes are still accepted.
+    REQUIRE(!img->add_channel(heif_channel_Cb, W / 2, H / 2, 8, nullptr));
+    REQUIRE(!img->add_channel(heif_channel_Cr, W / 2, H / 2, 8, nullptr));
+    CHECK(!img->check_plane_layout());
   }

   SECTION("alpha next to a filter array") {
diff --git a/tests/uncompressed_encode.cc b/tests/uncompressed_encode.cc
index ecc6994c..d979f876 100644
--- a/tests/uncompressed_encode.cc
+++ b/tests/uncompressed_encode.cc
@@ -1130,15 +1130,18 @@ TEST_CASE("Add tile rejects images that do not match the unci configuration")
   };

   SECTION("component plane larger than the tile") {
-    heif_image *prototype = createImage_YCbCr_customPlanes(TW, TH, heif_chroma_420, TW / 2, TH / 2, TW / 2, TH / 2);
-    // Declares the correct tile size, but its Cb plane is far larger than the
-    // subsampled size the encoder allocates for.
-    heif_image *tile = createImage_YCbCr_customPlanes(TW, TH, heif_chroma_420, 200, 200, TW / 2, TH / 2);
+    // A tile whose Cb plane is far larger than the subsampled size can no longer be
+    // built: heif_image_add_plane() now rejects a Cb/Cr plane that is not the
+    // chroma-subsampled size of the image (GHSA-j2rv-58fh-w8pw), so such a tile can
+    // never reach the encoder's per-tile guard. Verify the rejection at construction.
+    heif_image *tile;
+    REQUIRE(heif_image_create(TW, TH, heif_colorspace_YCbCr, heif_chroma_420, &tile).code == heif_error_Ok);
+    REQUIRE(heif_image_add_plane(tile, heif_channel_Y, TW, TH, 8).code == heif_error_Ok);

-    REQUIRE(add_tile(prototype, tile).code != heif_error_Ok);
+    // Cb should be TW/2 x TH/2; 200 x 200 is far too large.
+    REQUIRE(heif_image_add_plane(tile, heif_channel_Cb, 200, 200, 8).code != heif_error_Ok);

     heif_image_release(tile);
-    heif_image_release(prototype);
   }

   SECTION("chroma format differs from the prototype") {