Commit c7276222 for libheif

commit c72762228cace0a3fc2218cbb589586bedb447a7
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 19 23:49:30 2026 +0200

    Refuse a separate alpha plane on interleaved images (GHSA-qfj5-c4pq-q998)

    heif_image_add_plane() accepted a heif_channel_Alpha plane on an image whose
    chroma format is interleaved. The interleaved RGB encoders of the uncompressed
    codec took their component list from the chroma format (three entries for
    interleaved RGB) but decided whether to write alpha from has_alpha(), which
    also reports a separate alpha plane. With the two disagreeing, the encoder
    indexed the three-entry component list at [3]: a heap out-of-bounds read in
    release builds and an assertion failure in debug builds.

    An interleaved image carries its alpha inside the interleaved plane (RGBA,
    RRGGBBAA), so a separate alpha plane next to it is never a valid
    configuration. Reject it in HeifPixelImage::add_channel() with a usage error
    and document the precondition on the C API. As defense in depth, the
    uncompressed encoder factory refuses such an image too, since
    transfer_channel_from_image_as() can still assemble one without going through
    add_channel(), and both interleaved encoders now derive the alpha decision
    from the chroma format, the same source as the component list.

    The new check exposed that Op_YCbCr420_to_RRGGBBaa added a separate alpha
    plane to its RRGGBBAA output that it never wrote; the alpha samples already
    go into the interleaved plane. Remove the redundant, uninitialized plane.

    The out-of-bounds read is only reachable by an application constructing the
    inconsistent image through the public API; decoding never produces one.

diff --git a/libheif/api/libheif/heif_image.h b/libheif/api/libheif/heif_image.h
index 175ad8cc..347f7e06 100644
--- a/libheif/api/libheif/heif_image.h
+++ b/libheif/api/libheif/heif_image.h
@@ -371,6 +371,11 @@ heif_error heif_image_create(int width, int height,
  * <p>For backward compatibility, one can also specify 24bits for RGB and 32bits for RGBA,
  * instead of the preferred 8 bits. However, this use is deprecated.
  *
+ * <p>An image with an interleaved chroma format carries its alpha inside the interleaved
+ * plane. Adding a separate {@code heif_channel_Alpha} plane to such an image is rejected
+ * with an error. Use one of the interleaved formats with alpha
+ * (e.g. {@code heif_chroma_interleaved_RGBA}) instead.
+ *
  * @param image the parent image to add the channel plane to
  * @param channel the channel of the plane to add
  * @param width the width of the plane
diff --git a/libheif/codecs/uncompressed/unc_encoder.cc b/libheif/codecs/uncompressed/unc_encoder.cc
index 4ba36eb0..524e2fe2 100644
--- a/libheif/codecs/uncompressed/unc_encoder.cc
+++ b/libheif/codecs/uncompressed/unc_encoder.cc
@@ -176,6 +176,18 @@ Result<std::unique_ptr<const unc_encoder> > unc_encoder_factory::get_unc_encoder
                    heif_suberror_Unspecified,
                    "Image has an interleaved chroma format, but no interleaved pixel plane."};
     }
+
+    // Alpha of an interleaved image lives inside the interleaved plane (RGBA, RRGGBBAA). The
+    // interleaved encoders take their component list from the chroma format, so a separate alpha
+    // plane would make them address a fourth component that the chroma format does not have
+    // (GHSA-qfj5-c4pq-q998). HeifPixelImage::add_channel() refuses to build such an image, but
+    // transfer_channel_from_image_as() can still assemble one.
+    if (prototype_image->has_channel(heif_channel_Alpha)) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "Image has an interleaved chroma format and a separate alpha plane. "
+                   "Alpha has to be part of the interleaved format."};
+    }
   }
   else if (prototype_image->get_used_planar_component_ids().empty()) {
     return Error{heif_error_Invalid_input,
diff --git a/libheif/codecs/uncompressed/unc_encoder_rgb_bytealign_pixel_interleave.cc b/libheif/codecs/uncompressed/unc_encoder_rgb_bytealign_pixel_interleave.cc
index 756344c6..2f8ff864 100644
--- a/libheif/codecs/uncompressed/unc_encoder_rgb_bytealign_pixel_interleave.cc
+++ b/libheif/codecs/uncompressed/unc_encoder_rgb_bytealign_pixel_interleave.cc
@@ -61,7 +61,10 @@ unc_encoder_rgb_bytealign_pixel_interleave::unc_encoder_rgb_bytealign_pixel_inte
 {
   auto cmpd_ids = image->get_component_ids_interleaved();

-  bool save_alpha = image->has_alpha();
+  // Whether the pixels carry alpha is a property of the interleaved chroma format, which is also
+  // where the component list above comes from. has_alpha() would additionally report a separate
+  // alpha plane, which get_unc_encoder() rejects for interleaved images.
+  bool save_alpha = is_interleaved_with_alpha(image->get_chroma_format());

   m_bytes_per_pixel = save_alpha ? 8 : 6;
   assert(cmpd_ids.size() == m_bytes_per_pixel/2);
diff --git a/libheif/codecs/uncompressed/unc_encoder_rgb_pixel_interleave.cc b/libheif/codecs/uncompressed/unc_encoder_rgb_pixel_interleave.cc
index 26b4cc7c..53330f62 100644
--- a/libheif/codecs/uncompressed/unc_encoder_rgb_pixel_interleave.cc
+++ b/libheif/codecs/uncompressed/unc_encoder_rgb_pixel_interleave.cc
@@ -56,7 +56,11 @@ unc_encoder_rgb_pixel_interleave::unc_encoder_rgb_pixel_interleave(const std::sh
     : unc_encoder(image)
 {
   auto cmpd_ids = image->get_component_ids_interleaved();
-  bool save_alpha = image->has_alpha();
+
+  // Whether the pixels carry alpha is a property of the interleaved chroma format, which is also
+  // where the component list above comes from. has_alpha() would additionally report a separate
+  // alpha plane, which get_unc_encoder() rejects for interleaved images.
+  bool save_alpha = is_interleaved_with_alpha(image->get_chroma_format());

   m_bytes_per_pixel = save_alpha ? 4 : 3;
   assert(cmpd_ids.size() == m_bytes_per_pixel);
diff --git a/libheif/color-conversion/yuv2rgb.cc b/libheif/color-conversion/yuv2rgb.cc
index 3772ac3f..a8ad28e5 100644
--- a/libheif/color-conversion/yuv2rgb.cc
+++ b/libheif/color-conversion/yuv2rgb.cc
@@ -676,9 +676,8 @@ Op_YCbCr420_to_RRGGBBaa::convert_colorspace(const std::shared_ptr<const HeifPixe
       };
     }

-    if (auto err = outimg->add_channel(heif_channel_Alpha, width, height, bpp, limits)) {
-      return err;
-    }
+    // The alpha samples go into the fourth component of the interleaved RRGGBBAA plane below.
+    // The output must not carry a separate alpha plane in addition to that.
   }

   uint8_t* out_p;
diff --git a/libheif/image/pixelimage.cc b/libheif/image/pixelimage.cc
index 8120071d..bae4912c 100644
--- a/libheif/image/pixelimage.cc
+++ b/libheif/image/pixelimage.cc
@@ -348,6 +348,20 @@ Error HeifPixelImage::add_channel(heif_channel channel, uint32_t width, uint32_t
                                 const heif_security_limits* limits,
                                 heif_component_datatype datatype)
 {
+  // An interleaved image carries its alpha inside the interleaved plane (the RGBA and RRGGBBAA
+  // formats). A separate alpha plane next to it would let the chroma format and the set of planes
+  // disagree about whether the image has alpha. The interleaved encoders of the uncompressed codec
+  // took the component list from the chroma format but the alpha decision from the planes and read
+  // past the end of the component list (GHSA-qfj5-c4pq-q998). Callers that want alpha have to use
+  // an interleaved format with alpha instead.
+
+  if (channel == heif_channel_Alpha && num_interleaved_components_per_plane(m_chroma) > 1) {
+    return {heif_error_Usage_error,
+            heif_suberror_Unspecified,
+            "Cannot add a separate alpha plane to an image with an interleaved chroma format. "
+            "Use an interleaved format with alpha (e.g. heif_chroma_interleaved_RGBA) instead."};
+  }
+
   // for backwards compatibility, allow for 24/32 bits for RGB/RGBA interleaved chromas

   if (m_chroma == heif_chroma_interleaved_RGB && bit_depth == 24) {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index f993ca12..c1a22059 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -154,6 +154,7 @@ if (WITH_UNCOMPRESSED_CODEC)
     add_libheif_test(uncompressed_block_pixel_overpacked)
     add_libheif_test(uncompressed_idat_tiled)
     add_libheif_test(uncompressed_encode)
+    add_libheif_test(uncompressed_interleaved_alpha_plane)

     if (ENABLE_EXPERIMENTAL_FEATURES)
         add_libheif_test(uncompressed_encode_multicomponent)
diff --git a/tests/uncompressed_interleaved_alpha_plane.cc b/tests/uncompressed_interleaved_alpha_plane.cc
new file mode 100644
index 00000000..8e9315b4
--- /dev/null
+++ b/tests/uncompressed_interleaved_alpha_plane.cc
@@ -0,0 +1,216 @@
+/*
+  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.
+*/
+
+// Regression test for GHSA-qfj5-c4pq-q998.
+//
+// heif_image_add_plane() accepted a separate heif_channel_Alpha plane on an image
+// whose chroma format is interleaved. The interleaved RGB encoders of the
+// uncompressed codec took the component list from the chroma format (three
+// entries for interleaved RGB) but the decision whether to write alpha from
+// has_alpha(), which also reports a separate alpha plane. With the two
+// disagreeing, the encoder indexed the three-entry component list at [3]: a
+// heap out-of-bounds read in release builds and an assertion failure in debug
+// builds.
+//
+// An interleaved image carries its alpha inside the interleaved plane (the RGBA
+// and RRGGBBAA formats), so a separate alpha plane on such an image is never a
+// valid configuration. It is now rejected when the plane is added, and the
+// uncompressed encoder additionally refuses such an image should it be
+// assembled by another route.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "api_structs.h"
+#include "image/pixelimage.h"
+#include "codecs/uncompressed/unc_encoder.h"
+#include "test_utils.h"
+
+#include <cstring>
+#include <memory>
+
+namespace {
+
+constexpr int WIDTH = 16;
+constexpr int HEIGHT = 16;
+
+struct InterleavedFormat
+{
+  heif_chroma chroma;
+  int bit_depth;
+  const char* name;
+};
+
+// The bit depths are chosen so that every interleaved encoder of the uncompressed
+// codec is covered: 8-bit RGB/RGBA use the pixel-interleave encoder, RRGGBB below
+// 14 bits the block-pixel encoder, and the remaining formats the byte-aligned one.
+const InterleavedFormat interleaved_formats[] = {
+    {heif_chroma_interleaved_RGB, 8, "RGB"},
+    {heif_chroma_interleaved_RGBA, 8, "RGBA"},
+    {heif_chroma_interleaved_RRGGBB_LE, 10, "RRGGBB_LE"},
+    {heif_chroma_interleaved_RRGGBB_BE, 16, "RRGGBB_BE"},
+    {heif_chroma_interleaved_RRGGBBAA_LE, 10, "RRGGBBAA_LE"},
+    {heif_chroma_interleaved_RRGGBBAA_BE, 16, "RRGGBBAA_BE"},
+};
+
+
+void fill_plane(heif_image* image, heif_channel channel, uint8_t value)
+{
+  size_t stride = 0;
+  uint8_t* plane = heif_image_get_plane2(image, channel, &stride);
+  REQUIRE(plane != nullptr);
+
+  int height = heif_image_get_height(image, channel);
+  for (int y = 0; y < height; y++) {
+    memset(plane + y * stride, value, stride);
+  }
+}
+
+
+heif_error encode_uncompressed(heif_image* image)
+{
+  heif_context* ctx = heif_context_alloc();
+
+  heif_encoder* encoder = nullptr;
+  heif_error err = heif_context_get_encoder_for_format(ctx, heif_compression_uncompressed, &encoder);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_encoding_options* options = heif_encoding_options_alloc();
+  options->macOS_compatibility_workaround_no_nclx_profile = true;
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_encode_image(ctx, image, encoder, options, &handle);
+
+  if (handle) {
+    heif_image_handle_release(handle);
+  }
+  heif_encoding_options_free(options);
+  heif_encoder_release(encoder);
+  heif_context_free(ctx);
+
+  return err;
+}
+
+} // namespace
+
+
+TEST_CASE("heif_image_add_plane rejects a separate alpha plane on an interleaved image")
+{
+  for (const auto& fmt : interleaved_formats) {
+    INFO(fmt.name);
+
+    heif_image* image = nullptr;
+    heif_error err = heif_image_create(WIDTH, HEIGHT, heif_colorspace_RGB, fmt.chroma, &image);
+    REQUIRE(err.code == heif_error_Ok);
+
+    err = heif_image_add_plane(image, heif_channel_interleaved, WIDTH, HEIGHT, fmt.bit_depth);
+    REQUIRE(err.code == heif_error_Ok);
+    fill_plane(image, heif_channel_interleaved, 0x80);
+
+    err = heif_image_add_plane(image, heif_channel_Alpha, WIDTH, HEIGHT, fmt.bit_depth);
+    CHECK(err.code == heif_error_Usage_error);
+    CHECK(heif_image_has_channel(image, heif_channel_Alpha) == 0);
+
+    // Without the separate alpha plane the image is valid and still encodes. Under the
+    // unfixed library this call read past the end of the interleaved component list for
+    // the formats without alpha.
+    CHECK(encode_uncompressed(image).code == heif_error_Ok);
+
+    heif_image_release(image);
+  }
+}
+
+
+TEST_CASE("heif_image_add_plane rejects a separate alpha plane before the interleaved plane")
+{
+  // The chroma format is fixed at heif_image_create(), so the order in which the planes
+  // are added must not matter.
+
+  heif_image* image = nullptr;
+  heif_error err = heif_image_create(WIDTH, HEIGHT, heif_colorspace_RGB, heif_chroma_interleaved_RGB, &image);
+  REQUIRE(err.code == heif_error_Ok);
+
+  err = heif_image_add_plane(image, heif_channel_Alpha, WIDTH, HEIGHT, 8);
+  CHECK(err.code == heif_error_Usage_error);
+  CHECK(heif_image_has_channel(image, heif_channel_Alpha) == 0);
+
+  heif_image_release(image);
+}
+
+
+TEST_CASE("Planar RGB images still accept a separate alpha plane")
+{
+  heif_image* image = nullptr;
+  heif_error err = heif_image_create(WIDTH, HEIGHT, heif_colorspace_RGB, heif_chroma_444, &image);
+  REQUIRE(err.code == heif_error_Ok);
+
+  for (heif_channel channel : {heif_channel_R, heif_channel_G, heif_channel_B, heif_channel_Alpha}) {
+    err = heif_image_add_plane(image, channel, WIDTH, HEIGHT, 8);
+    REQUIRE(err.code == heif_error_Ok);
+    fill_plane(image, channel, 0x80);
+  }
+
+  CHECK(heif_image_has_channel(image, heif_channel_Alpha) == 1);
+  CHECK(encode_uncompressed(image).code == heif_error_Ok);
+
+  heif_image_release(image);
+}
+
+
+// transfer_channel_from_image_as() moves a plane between images without going through
+// add_channel(). This is how a decoded alpha auxiliary image is attached to the main
+// image, so an interleaved image with a separate alpha plane can still be assembled
+// inside the library. The uncompressed encoder must refuse it instead of reading past
+// the end of the interleaved component list.
+TEST_CASE("Uncompressed encoder refuses an interleaved image carrying a separate alpha plane")
+{
+  const heif_security_limits* limits = heif_get_global_security_limits();
+
+  for (const auto& fmt : interleaved_formats) {
+    INFO(fmt.name);
+
+    auto image = std::make_shared<HeifPixelImage>();
+    image->create(WIDTH, HEIGHT, heif_colorspace_RGB, fmt.chroma);
+    REQUIRE(image->fill_new_channel(heif_channel_interleaved, 0x80, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
+
+    auto alpha = std::make_shared<HeifPixelImage>();
+    alpha->create(WIDTH, HEIGHT, heif_colorspace_monochrome, heif_chroma_monochrome);
+    REQUIRE(alpha->fill_new_channel(heif_channel_Y, 0xFF, WIDTH, HEIGHT, fmt.bit_depth, limits).error_code == heif_error_Ok);
+
+    REQUIRE(image->transfer_channel_from_image_as(alpha, heif_channel_Y, heif_channel_Alpha).error_code == heif_error_Ok);
+    REQUIRE(image->has_channel(heif_channel_Alpha));
+
+    // The encoder factory is where the interleaved encoders are instantiated.
+    heif_encoding_options* options = heif_encoding_options_alloc();
+    auto encoder = unc_encoder_factory::get_unc_encoder(image, *options);
+    heif_encoding_options_free(options);
+    CHECK(!encoder);
+
+    // Same through the public encode entry point.
+    heif_image wrapper;
+    wrapper.image = image;
+    CHECK(encode_uncompressed(&wrapper).code != heif_error_Ok);
+  }
+}