Commit 646d85fb for libheif

commit 646d85fbf5bd18fc3cdc516e915e59072de2d510
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Aug 24 22:30:44 2026 +0200

    validate inline mask data length in region writer API (GHSA-p58j-h3vm-3fp5)

    heif_region_item_add_region_inline_mask_data() stored the caller-supplied
    buffer using mask_data_len verbatim, without checking it against the region
    geometry. The reader (heif_region_get_inline_mask_image) derives the number
    of mask bytes from width*height, so an undersized buffer caused a heap
    out-of-bounds read whose bytes were also copied into the returned mask image,
    potentially disclosing adjacent heap memory. Not reachable through file
    parsing: RegionGeometry_InlineMask::parse already validates the input.

    The writer now rejects a NULL buffer, zero width/height, and any mask_data_len
    that does not equal the canonical (width*height+7)/8 bytes. An oversized buffer
    is rejected as well, since a surplus indicates that geometry and data disagree
    and the extra bytes would desynchronize on-disk parsing of following regions.
    As defence in depth, heif_region_get_inline_mask_image() clamps its reads to
    the actual buffer size.

diff --git a/libheif/api/libheif/heif_regions.cc b/libheif/api/libheif/heif_regions.cc
index 8ea1104b..6ae402c4 100644
--- a/libheif/api/libheif/heif_regions.cc
+++ b/libheif/api/libheif/heif_regions.cc
@@ -409,12 +409,43 @@ heif_error heif_region_item_add_region_inline_mask_data(heif_region_item* item,
                                                         size_t mask_data_len,
                                                         heif_region** out_region)
 {
+  if (out_region) {
+    *out_region = nullptr;
+  }
+
+  if (mask_data == nullptr) {
+    return heif_error_null_pointer_argument;
+  }
+
+  if (width == 0 || height == 0) {
+    return {heif_error_Invalid_input, heif_suberror_Invalid_region_data,
+            "Inline mask region has zero width or height"};
+  }
+
+  // The mask is stored with one bit per pixel, no padding between rows. Only the
+  // very last byte is padded. This is the same canonical size that the file parser
+  // (RegionGeometry_InlineMask::parse) and the reader (heif_region_get_mask_image)
+  // compute from the geometry. The caller-supplied buffer must hold exactly that
+  // many bytes: a shorter buffer would make the reader run off the end of the
+  // allocation, and a longer buffer indicates that the caller's geometry and mask
+  // data disagree, which we reject rather than silently discard.
+  uint64_t mask_size = (static_cast<uint64_t>(width) * height + 7) / 8;
+  if (mask_size > std::numeric_limits<size_t>::max()) {
+    return {heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+            "Inline mask size overflow"};
+  }
+
+  if (mask_data_len != static_cast<size_t>(mask_size)) {
+    return {heif_error_Invalid_input, heif_suberror_Invalid_region_data,
+            "Inline mask data length does not match the given region size"};
+  }
+
   auto region = std::make_shared<RegionGeometry_InlineMask>();
   region->x = x;
   region->y = y;
   region->width = width;
   region->height = height;
-  region->mask_data.resize(mask_data_len);
+  region->mask_data.resize(static_cast<size_t>(mask_size));
   std::memcpy(region->mask_data.data(), mask_data, region->mask_data.size());

   item->region_item->add_region(region);
@@ -442,7 +473,11 @@ static heif_error heif_region_get_inline_mask_image(const heif_region* region,
     *out_y0 = mask->y;
     uint32_t width = *out_width = mask->width;
     uint32_t height = *out_height = mask->height;
-    uint8_t* mask_data = mask->mask_data.data();
+    const uint8_t* mask_data = mask->mask_data.data();
+    // Defence in depth: the writer API and the file parser both guarantee that
+    // mask_data holds ceil(width*height/8) bytes, but never read beyond the actual
+    // buffer even if that invariant were somehow violated.
+    size_t mask_data_len = mask->mask_data.size();

     heif_error err = heif_image_create(width, height, heif_colorspace_monochrome, heif_chroma_monochrome, out_mask_image);
     if (err.code) {
@@ -462,7 +497,7 @@ static heif_error heif_region_get_inline_mask_image(const heif_region* region,
         uint64_t mask_byte = pixel_index / 8;
         uint8_t pixel_bit = uint8_t(0x80U >> (pixel_index % 8));

-        p[y * stride + x] = (mask_data[mask_byte] & pixel_bit) ? 255 : 0;
+        p[y * stride + x] = (mask_byte < mask_data_len && (mask_data[mask_byte] & pixel_bit)) ? 255 : 0;

         pixel_index++;
       }
diff --git a/libheif/api/libheif/heif_regions.h b/libheif/api/libheif/heif_regions.h
index d9a3f61f..eaa9c170 100644
--- a/libheif/api/libheif/heif_regions.h
+++ b/libheif/api/libheif/heif_regions.h
@@ -814,12 +814,16 @@ heif_error heif_region_item_add_region_referenced_mask(heif_region_item* region_
  * part of the region. If the bit value is `0`, the corresponding pixel is not part of the
  * region.
  *
+ * @c width and @c height must both be non-zero and @c mask_data_len must be exactly
+ * `(width * height + 7) / 8`, the number of bytes needed to hold one bit per pixel.
+ * Otherwise the function fails without modifying the region item.
+ *
  * @param region_item the region item that holds this mask region
  * @param x the x value for the top-left corner of this mask region
  * @param y the y value for the top-left corner of this mask region
  * @param width the width of this mask region
  * @param height the height of this mask region
- * @param mask_data the location to return the mask data
+ * @param mask_data the mask data to store
  * @param mask_data_len the length of the mask data, in bytes
  * @param out_region pointer to pointer to the returned region (optional, see below)
  * @return heif_error_ok on success, or an error value indicating the problem on failure
diff --git a/tests/region.cc b/tests/region.cc
index db2bd1e0..e32bd952 100644
--- a/tests/region.cc
+++ b/tests/region.cc
@@ -798,3 +798,179 @@ TEST_CASE("inline mask region from oversized image is cropped") {
   heif_context_free(ctx);
   heif_image_release(img);
 }
+
+
+TEST_CASE("inline mask region data length must match region geometry") {
+  // Regression test for GHSA-p58j-h3vm-3fp5: heif_region_item_add_region_inline_mask_data()
+  // stored the caller-supplied buffer verbatim without checking that it holds at least
+  // (width*height+7)/8 bytes. heif_region_get_mask_image() derives the number of mask
+  // bytes to read from the region geometry, so an undersized buffer led to a heap
+  // out-of-bounds read whose bytes were copied into the returned mask image.
+  struct heif_error err;
+
+  heif_context* ctx = heif_context_alloc();
+  heif_encoder* enc;
+  err = heif_context_get_encoder_for_format(ctx, heif_compression_AV1, &enc);
+  REQUIRE(err.code == heif_error_Ok);
+
+  uint32_t input_width = 64;
+  uint32_t input_height = 64;
+  heif_image* img;
+  heif_image_create(input_width, input_height, heif_colorspace_YCbCr,
+                    heif_chroma_420, &img);
+  fill_new_plane(img, heif_channel_Y, input_width, input_height);
+  fill_new_plane(img, heif_channel_Cb, (input_width + 1) / 2, (input_height + 1) / 2);
+  fill_new_plane(img, heif_channel_Cr, (input_width + 1) / 2, (input_height + 1) / 2);
+
+  heif_image_handle* handle;
+  err = heif_context_encode_image(ctx, img, enc, nullptr, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+
+  struct heif_region_item* region_item;
+  err = heif_image_handle_add_region_item(handle, input_width, input_height, &region_item);
+  REQUIRE(err.code == heif_error_Ok);
+
+  // A 64x3 region needs (64*3+7)/8 = 24 bytes of mask data.
+  const uint32_t region_width = 64;
+  const uint32_t region_height = 3;
+  const size_t required_len = (region_width * region_height + 7) / 8;
+  REQUIRE(required_len == 24);
+
+  heif_region* out_region = nullptr;
+
+  // Only 1 byte instead of 24. With the bug this was accepted and rendering the
+  // mask afterwards read 23 bytes past the end of the 1-byte allocation.
+  std::vector<uint8_t> short_mask_data(1, 0xff);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     short_mask_data.data(), short_mask_data.size(),
+                                                     &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+
+  // Off by one (23 of 24 bytes) must be rejected as well.
+  std::vector<uint8_t> almost_mask_data(required_len - 1, 0xff);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     almost_mask_data.data(), almost_mask_data.size(),
+                                                     &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+
+  // The file parser rejects zero-sized inline masks, so the writer must not produce them.
+  std::vector<uint8_t> mask_data(required_len, 0x00);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, 0, region_height,
+                                                     mask_data.data(), mask_data.size(), &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, 0,
+                                                     mask_data.data(), mask_data.size(), &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+
+  // NULL mask data.
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     nullptr, required_len, &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+
+  // None of the rejected calls may have added a region.
+  REQUIRE(heif_region_item_get_number_of_regions(region_item) == 0);
+
+  // A buffer of exactly the required size is accepted and can be rendered as a mask image.
+  mask_data[0] = 0x80;  // pixel (0,0)
+  mask_data[23] = 0x01; // pixel (63,2), the very last pixel of the region
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     mask_data.data(), mask_data.size(), &out_region);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(out_region != nullptr);
+  REQUIRE(heif_region_get_inline_mask_data_len(out_region) == required_len);
+
+  int32_t x, y;
+  uint32_t width, height;
+  heif_image* mask_image = nullptr;
+  err = heif_region_get_mask_image(out_region, &x, &y, &width, &height, &mask_image);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(mask_image != nullptr);
+  REQUIRE(x == 20);
+  REQUIRE(y == 50);
+  REQUIRE(width == 64);
+  REQUIRE(height == 3);
+  int stride;
+  const uint8_t* p = heif_image_get_plane_readonly(mask_image, heif_channel_Y, &stride);
+  REQUIRE(p[0] == 0xff);
+  REQUIRE(p[1] == 0x00);
+  REQUIRE(p[2 * stride + 62] == 0x00);
+  REQUIRE(p[2 * stride + 63] == 0xff);
+  heif_image_release(mask_image);
+  heif_region_release(out_region);
+  out_region = nullptr;
+
+  // A buffer larger than required is rejected as well: a surplus indicates that the
+  // caller's geometry and mask data disagree. (Silently keeping only the canonical
+  // prefix would also risk desynchronizing the on-disk parsing of following regions,
+  // since the format stores exactly (width*height+7)/8 bytes.)
+  std::vector<uint8_t> long_mask_data(required_len + 8, 0xaa);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     long_mask_data.data(), long_mask_data.size(),
+                                                     &out_region);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(out_region == nullptr);
+
+  // Add a second, correctly-sized inline mask so the round-trip below exercises
+  // parsing of multiple consecutive inline-mask regions.
+  std::vector<uint8_t> mask_data2(required_len, 0xaa);
+  err = heif_region_item_add_region_inline_mask_data(region_item, 20, 50, region_width, region_height,
+                                                     mask_data2.data(), mask_data2.size(), &out_region);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(out_region != nullptr);
+  REQUIRE(heif_region_get_inline_mask_data_len(out_region) == required_len);
+  heif_region_release(out_region);
+
+  err = heif_region_item_add_region_point(region_item, 5, 7, nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  err = heif_context_write_to_file(ctx, "regions_mask_inline_data_lengths.heif");
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_region_item_release(region_item);
+  heif_image_handle_release(handle);
+  heif_encoder_release(enc);
+  heif_context_free(ctx);
+  heif_image_release(img);
+
+  // Read the file back: all three regions must still be parsable and intact.
+  heif_context* readbackCtx = get_context_for_local_file("regions_mask_inline_data_lengths.heif");
+  heif_image_handle* readbackHandle = get_primary_image_handle(readbackCtx);
+  REQUIRE(heif_image_handle_get_number_of_region_items(readbackHandle) == 1);
+  heif_item_id region_item_id;
+  REQUIRE(heif_image_handle_get_list_of_region_item_ids(readbackHandle, &region_item_id, 1) == 1);
+  heif_region_item* in_region_item;
+  err = heif_context_get_region_item(readbackCtx, region_item_id, &in_region_item);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(heif_region_item_get_number_of_regions(in_region_item) == 3);
+  heif_region* regions[3];
+  REQUIRE(heif_region_item_get_list_of_regions(in_region_item, regions, 3) == 3);
+  REQUIRE(heif_region_get_type(regions[0]) == heif_region_type_inline_mask);
+  REQUIRE(heif_region_get_type(regions[1]) == heif_region_type_inline_mask);
+  REQUIRE(heif_region_get_type(regions[2]) == heif_region_type_point);
+  REQUIRE(heif_region_get_inline_mask_data_len(regions[0]) == required_len);
+  REQUIRE(heif_region_get_inline_mask_data_len(regions[1]) == required_len);
+
+  std::vector<uint8_t> mask_data_in(required_len);
+  err = heif_region_get_inline_mask_data(regions[1], &x, &y, &width, &height, mask_data_in.data());
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(width == 64);
+  REQUIRE(height == 3);
+  REQUIRE(mask_data_in[0] == 0xaa);
+  REQUIRE(mask_data_in[23] == 0xaa);
+
+  int32_t px, py;
+  err = heif_region_get_point(regions[2], &px, &py);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(px == 5);
+  REQUIRE(py == 7);
+
+  heif_region_release_many(regions, 3);
+  heif_region_item_release(in_region_item);
+  heif_image_handle_release(readbackHandle);
+  heif_context_free(readbackCtx);
+}