Commit bc7cf376 for libheif

commit bc7cf376a397b34530b867f975c261f7bb7e7c30
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Aug 24 00:55:36 2026 +0200

    scale_nearest_neighbor() rejects processing images with alpha channels of different size (GHSA-2jg2-4ch7-h545, F03)

diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index ce8ac691..d131d5ad 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -804,6 +804,41 @@ bool HeifPixelImage::primary_planes_have_size(uint32_t width, uint32_t height) c
 }


+bool HeifPixelImage::has_standard_plane_sizes() const
+{
+  for (const auto& component : m_storage) {
+    uint32_t expected_w, expected_h;
+
+    switch (component.m_channel) {
+      case heif_channel_Y:
+      case heif_channel_Alpha:
+      case heif_channel_R:
+      case heif_channel_G:
+      case heif_channel_B:
+      case heif_channel_interleaved:
+        expected_w = m_width;
+        expected_h = m_height;
+        break;
+
+      case heif_channel_Cb:
+      case heif_channel_Cr:
+        get_subsampled_size(m_width, m_height, component.m_channel, m_chroma, &expected_w, &expected_h);
+        break;
+
+      default:
+        // Not one of the standard channels this check knows the geometry of.
+        return false;
+    }
+
+    if (component.m_width != expected_w || component.m_height != expected_h) {
+      return false;
+    }
+  }
+
+  return true;
+}
+
+
 std::set<heif_channel> HeifPixelImage::get_channel_set() const
 {
   std::set<heif_channel> channels;
@@ -1816,10 +1851,23 @@ Error HeifPixelImage::overlay(std::shared_ptr<HeifPixelImage>& overlay, int32_t
 }


+// TODO: rewrite this to handle multi-spectral images.
 Error HeifPixelImage::scale_nearest_neighbor(std::shared_ptr<HeifPixelImage>& out_img,
                                              uint32_t width, uint32_t height,
                                              const heif_security_limits* limits) const
 {
+  // The per-channel loop below indexes each source plane using coordinates
+  // derived from m_width/m_height (or, for Cb/Cr, their chroma-subsampled
+  // size), trusting that the plane actually covers that geometry. Nothing
+  // else in this class enforces that as an invariant (a plane can be added at
+  // any size through add_channel()/copy_new_channel_from()/
+  // transfer_channel_from_image_as()), so verify it explicitly before doing
+  // any work.
+  if (!has_standard_plane_sizes()) {
+    return {heif_error_Unsupported_feature, heif_suberror_Unspecified,
+            "Scaling an image with non-standard plane sizes is not supported"};
+  }
+
   out_img = std::make_shared<HeifPixelImage>();
   out_img->create(width, height, m_colorspace, m_chroma);

@@ -1888,6 +1936,16 @@ Error HeifPixelImage::scale_nearest_neighbor(std::shared_ptr<HeifPixelImage>& ou
     }
   }

+  // The per-channel loop below trusts that every source plane has a matching,
+  // correctly-sized destination plane, established by has_channel() checks
+  // per iteration.
+  // This only works for ordinary images. Images with extra planes cannot
+  // currently be scaled (TODO).
+  if (m_storage.size() > out_img->m_storage.size()) {
+    return {heif_error_Unsupported_feature, heif_suberror_Unspecified,
+            "Images with extra planes are not supported by scale_nearest_neighbor()."};
+  }
+

   // --- scale all channels

diff --git a/libheif/image/pixelimage.h b/libheif/image/pixelimage.h
index 27f9dea4..3a5dc13b 100644
--- a/libheif/image/pixelimage.h
+++ b/libheif/image/pixelimage.h
@@ -107,6 +107,18 @@ public:
   //   undefined / custom  -> not checked here; returns true
   bool primary_planes_have_size(uint32_t width, uint32_t height) const;

+  // Returns true if every plane actually stored in m_storage -- not just the
+  // "primary" ones for the current colorspace -- has the size that plane's
+  // channel is expected to have: m_width x m_height for Y/Alpha/R/G/B/
+  // interleaved, and the chroma-subsampled size (via get_subsampled_size())
+  // for Cb/Cr. Any other channel type fails the check. Unlike
+  // primary_planes_have_size(), this checks each stored component directly
+  // rather than going through get_width(channel)/get_height(channel) (which
+  // only ever sees the first plane for a channel), so it also catches a
+  // same-channel duplicate whose size doesn't match, not just a missing or
+  // undersized single plane.
+  bool has_standard_plane_sizes() const;
+
   heif_chroma get_chroma_format() const { return m_chroma; }

   heif_colorspace get_colorspace() const { return m_colorspace; }
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index f39c97f4..66babeae 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -42,6 +42,7 @@ else()
     add_libheif_test(conversion)
     add_libheif_test(duplicate_alpha_channel)
     add_libheif_test(idat)
+    add_libheif_test(scale_plane_checks)
     add_libheif_test(jpeg2000)
     add_libheif_test(avc_box)
     add_libheif_test(file_layout)
diff --git a/tests/scale_plane_checks.cc b/tests/scale_plane_checks.cc
new file mode 100644
index 00000000..37bc07ce
--- /dev/null
+++ b/tests/scale_plane_checks.cc
@@ -0,0 +1,160 @@
+/*
+  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 HeifPixelImage::scale_nearest_neighbor()'s plane checks.
+//
+// The per-channel loop that does the actual scaling indexes each source plane
+// using coordinates derived from the image's logical size, trusting that the
+// plane actually covers that geometry. Nothing about add_channel()/
+// copy_new_channel_from()/transfer_channel_from_image_as() enforces that as an
+// invariant, so two independent guards are needed:
+//  - has_standard_plane_sizes() rejects any single plane (Y, Alpha, R, G, B,
+//    interleaved: must match the full image size; Cb, Cr: must match the
+//    chroma-subsampled size) whose actual size doesn't match what its channel
+//    is expected to have -- this covers a single mismatched plane of any
+//    channel, not just Alpha;
+//  - a plane-count check rejects the source having more planes of a given
+//    channel than were allocated for the destination (a same-channel
+//    duplicate, e.g. from add_channel()/copy_new_channel_from() being called
+//    twice for the same channel -- neither rejects duplicates the way
+//    transfer_channel_from_image_as() does): each stored duplicate can have
+//    the *correct* size and still slip past has_standard_plane_sizes(), so
+//    the loop would write every one of them into the single allocated
+//    destination plane, including whatever bit-depth mismatch it carries.
+
+TEST_CASE("scale_nearest_neighbor rejects a duplicate color channel") {
+  auto* limits = heif_get_global_security_limits();
+
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(4, 4, heif_colorspace_monochrome, heif_chroma_monochrome);
+  REQUIRE(image->add_channel(heif_channel_Y, 4, 4, 8, limits).error_code == heif_error_Ok);
+
+  // A second, differently-sized-per-sample Y plane. Neither add_channel() nor
+  // copy_new_channel_from() reject an already-occupied channel, so this is
+  // reachable without going through transfer_channel_from_image_as() (which
+  // does reject it).
+  REQUIRE(image->add_channel(heif_channel_Y, 4, 4, 16, limits).error_code == heif_error_Ok);
+
+  std::shared_ptr<HeifPixelImage> scaled;
+  Error err = image->scale_nearest_neighbor(scaled, 8, 8, limits);
+  REQUIRE(err.error_code == heif_error_Unsupported_feature);
+}
+
+TEST_CASE("scale_nearest_neighbor rejects an Alpha plane whose size differs from the color planes") {
+  auto* limits = heif_get_global_security_limits();
+
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(8, 8, heif_colorspace_monochrome, heif_chroma_monochrome);
+  REQUIRE(image->add_channel(heif_channel_Y, 8, 8, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Alpha, 4, 4, 8, limits).error_code == heif_error_Ok);
+
+  std::shared_ptr<HeifPixelImage> scaled;
+  Error err = image->scale_nearest_neighbor(scaled, 16, 16, limits);
+  REQUIRE(err.error_code == heif_error_Unsupported_feature);
+}
+
+TEST_CASE("scale_nearest_neighbor rejects a single color channel whose size doesn't match the image") {
+  auto* limits = heif_get_global_security_limits();
+
+  // Not a duplicate: exactly one Y plane, but sized smaller than the image's
+  // own declared 256x256 -- reachable directly through the public plane API
+  // (heif_image_create() + heif_image_add_plane()), with no decode involved.
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(256, 256, heif_colorspace_monochrome, heif_chroma_monochrome);
+  REQUIRE(image->add_channel(heif_channel_Y, 8, 8, 8, limits).error_code == heif_error_Ok);
+
+  std::shared_ptr<HeifPixelImage> scaled;
+  Error err = image->scale_nearest_neighbor(scaled, 512, 512, limits);
+  REQUIRE(err.error_code == heif_error_Unsupported_feature);
+}
+
+TEST_CASE("scale_nearest_neighbor scales a well-formed YCbCr 4:2:0 image correctly") {
+  auto* limits = heif_get_global_security_limits();
+
+  // Cb/Cr are legitimately half-size in 4:2:0 -- has_standard_plane_sizes()
+  // must not reject that.
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(4, 4, heif_colorspace_YCbCr, heif_chroma_420);
+  REQUIRE(image->add_channel(heif_channel_Y, 4, 4, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Cb, 2, 2, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Cr, 2, 2, 8, limits).error_code == heif_error_Ok);
+
+  std::shared_ptr<HeifPixelImage> scaled;
+  Error err = image->scale_nearest_neighbor(scaled, 8, 8, limits);
+  REQUIRE(err.error_code == heif_error_Ok);
+  REQUIRE(scaled->get_width(heif_channel_Y) == 8);
+  REQUIRE(scaled->get_height(heif_channel_Y) == 8);
+  REQUIRE(scaled->get_width(heif_channel_Cb) == 4);
+  REQUIRE(scaled->get_height(heif_channel_Cb) == 4);
+}
+
+TEST_CASE("scale_nearest_neighbor scales a well-formed image with matching Alpha correctly") {
+  auto* limits = heif_get_global_security_limits();
+
+  auto image = std::make_shared<HeifPixelImage>();
+  image->create(2, 2, heif_colorspace_monochrome, heif_chroma_monochrome);
+  REQUIRE(image->add_channel(heif_channel_Y, 2, 2, 8, limits).error_code == heif_error_Ok);
+  REQUIRE(image->add_channel(heif_channel_Alpha, 2, 2, 8, limits).error_code == heif_error_Ok);
+
+  {
+    size_t stride;
+    uint8_t* y = image->get_channel_memory(heif_channel_Y, &stride);
+    y[0 * stride + 0] = 10;
+    y[0 * stride + 1] = 20;
+    y[1 * stride + 0] = 30;
+    y[1 * stride + 1] = 40;
+
+    uint8_t* a = image->get_channel_memory(heif_channel_Alpha, &stride);
+    a[0 * stride + 0] = 100;
+    a[0 * stride + 1] = 110;
+    a[1 * stride + 0] = 120;
+    a[1 * stride + 1] = 130;
+  }
+
+  std::shared_ptr<HeifPixelImage> scaled;
+  Error err = image->scale_nearest_neighbor(scaled, 4, 4, limits);
+  REQUIRE(err.error_code == heif_error_Ok);
+  REQUIRE(scaled->get_width(heif_channel_Y) == 4);
+  REQUIRE(scaled->get_height(heif_channel_Y) == 4);
+  REQUIRE(scaled->has_channel(heif_channel_Alpha));
+  REQUIRE(scaled->get_width(heif_channel_Alpha) == 4);
+  REQUIRE(scaled->get_height(heif_channel_Alpha) == 4);
+
+  // Nearest-neighbor 2x2 -> 4x4: each source pixel covers a 2x2 block.
+  size_t stride;
+  const uint8_t* y = scaled->get_channel_memory(heif_channel_Y, &stride);
+  REQUIRE(y[0 * stride + 0] == 10);
+  REQUIRE(y[0 * stride + 3] == 20);
+  REQUIRE(y[3 * stride + 0] == 30);
+  REQUIRE(y[3 * stride + 3] == 40);
+
+  const uint8_t* a = scaled->get_channel_memory(heif_channel_Alpha, &stride);
+  REQUIRE(a[0 * stride + 0] == 100);
+  REQUIRE(a[3 * stride + 3] == 130);
+}