Commit 21893e8f for libheif

commit 21893e8f66c8b79363ca2809391b07d35e1a95ec
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Mon Aug 24 18:47:59 2026 +0200

    bound brotli/zlib decompression output by security limits (GHSA-24wx-9w62-c96w)

diff --git a/libheif/codecs/uncompressed/unc_decoder.cc b/libheif/codecs/uncompressed/unc_decoder.cc
index bad7f158..9aeba61b 100644
--- a/libheif/codecs/uncompressed/unc_decoder.cc
+++ b/libheif/codecs/uncompressed/unc_decoder.cc
@@ -115,6 +115,10 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
   std::shared_ptr<const Box_cmpC> cmpC_box = properties.cmpC;
   std::shared_ptr<const Box_icef> icef_box = properties.icef;

+  // Security limits used to bound the (potentially highly amplified) decompressed
+  // output. May be nullptr for a raw data extent, in which case no limit applies.
+  const heif_security_limits* limits = dataExtent.m_file ? dataExtent.m_file->get_security_limits() : nullptr;
+
   if (!cmpC_box) {
     // assume no generic compression
     auto readResult = dataExtent.read_data(range_start_offset, range_size);
@@ -148,7 +152,7 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
     const std::vector<uint8_t>& compressed_bytes = *readingResult;

     // decompress only the unit
-    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes);
+    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits);
     if (!dataResult) {
       return dataResult.error();
     }
@@ -164,6 +168,14 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent

     const std::vector<uint8_t> compressed_bytes = std::move(**readResult);

+    // Bound the total decompressed output accumulated across all units. The units
+    // may overlap (nothing forces them to be disjoint), so N units can point at the
+    // same compressed slice and be decompressed N times into `data`. Without this
+    // accounting, that amplifies a tiny icef box into an unbounded allocation
+    // (GHSA-24wx-9w62-c96w). The handle is local, so it only bounds the peak while
+    // building `data`; the cropped result is accounted by the caller.
+    MemoryHandle accumulated_memory_handle;
+
     for (Box_icef::CompressedUnitInfo unit_info : icef_box->get_units()) {
       // Use subtraction form to avoid a uint64_t wrap in 'unit_offset + unit_size',
       // which could otherwise pass this check and lead to an out-of-bounds read when
@@ -181,12 +193,18 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
       auto unit_end = unit_start + unit_info.unit_size;
       std::vector<uint8_t> compressed_unit_data = std::vector<uint8_t>(unit_start, unit_end);

-      auto dataResult = do_decompress_data(cmpC_box, std::move(compressed_unit_data));
+      auto dataResult = do_decompress_data(cmpC_box, std::move(compressed_unit_data), limits);
       if (!dataResult) {
         return dataResult.error();
       }

       const std::vector<uint8_t> uncompressed_unit_data = std::move(*dataResult);
+
+      if (Error memErr = accumulated_memory_handle.alloc(uncompressed_unit_data.size(), limits,
+                                                         "unci icef decompressed units")) {
+        return memErr;
+      }
+
       data->insert(data->end(), uncompressed_unit_data.data(), uncompressed_unit_data.data() + uncompressed_unit_data.size());
     }

@@ -213,7 +231,7 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent
     std::vector<uint8_t> compressed_bytes = std::move(**readResult);

     // Decode as a single blob
-    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes);
+    auto dataResult = do_decompress_data(cmpC_box, compressed_bytes, limits);
     if (!dataResult) {
       return dataResult.error();
     }
@@ -238,11 +256,12 @@ const Error unc_decoder::get_compressed_image_data_uncompressed(const DataExtent


 Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<const Box_cmpC>& cmpC_box,
-                                                              std::vector<uint8_t> compressed_data) const
+                                                              std::vector<uint8_t> compressed_data,
+                                                              const heif_security_limits* limits) const
 {
   if (cmpC_box->get_compression_type() == fourcc("brot")) {
 #if HAVE_BROTLI
-    return decompress_brotli(compressed_data);
+    return decompress_brotli(compressed_data, limits);
 #else
     std::stringstream sstr;
     sstr << "cannot decode unci item with brotli compression - not enabled" << std::endl;
@@ -253,7 +272,7 @@ Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<co
   }
   else if (cmpC_box->get_compression_type() == fourcc("zlib")) {
 #if HAVE_ZLIB
-    return decompress_zlib(compressed_data);
+    return decompress_zlib(compressed_data, limits);
 #else
     std::stringstream sstr;
     sstr << "cannot decode unci item with zlib compression - not enabled" << std::endl;
@@ -264,7 +283,7 @@ Result<std::vector<uint8_t> > unc_decoder::do_decompress_data(std::shared_ptr<co
   }
   else if (cmpC_box->get_compression_type() == fourcc("defl")) {
 #if HAVE_ZLIB
-    return decompress_deflate(compressed_data);
+    return decompress_deflate(compressed_data, limits);
 #else
     std::stringstream sstr;
     sstr << "cannot decode unci item with deflate compression - not enabled" << std::endl;
diff --git a/libheif/codecs/uncompressed/unc_decoder.h b/libheif/codecs/uncompressed/unc_decoder.h
index dc566ab0..49e4d994 100644
--- a/libheif/codecs/uncompressed/unc_decoder.h
+++ b/libheif/codecs/uncompressed/unc_decoder.h
@@ -75,7 +75,8 @@ protected:
                                                      const Box_iloc::Item* item) const;

   Result<std::vector<uint8_t>> do_decompress_data(std::shared_ptr<const Box_cmpC>& cmpC_box,
-                                                  std::vector<uint8_t> compressed_data) const;
+                                                  std::vector<uint8_t> compressed_data,
+                                                  const heif_security_limits* limits) const;

   const uint32_t m_width;
   const uint32_t m_height;
diff --git a/libheif/compression.h b/libheif/compression.h
index 61c79766..38efca02 100644
--- a/libheif/compression.h
+++ b/libheif/compression.h
@@ -27,6 +27,8 @@
 #include <error.h>
 #include <libheif/heif_uncompressed.h>

+struct heif_security_limits;
+
 /**
  * Convert heif_unci_compression enum to a fourcc code.
  *
@@ -81,7 +83,8 @@ std::vector<uint8_t> compress_deflate(const uint8_t* input, size_t size);
  * @sa decompress_deflate
  * @sa compress_zlib
  */
-Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input);
+Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input,
+                                             const heif_security_limits* limits);

 /**
  * Decompress "deflate" compressed data.
@@ -95,7 +98,8 @@ Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compres
  * @sa decompress_zlib
  * @sa compress_deflate
  */
-Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input);
+Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
+                                                const heif_security_limits* limits);

 #endif

@@ -109,7 +113,8 @@ Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& comp
  * @param output pointer to the resulting vector of decompressed data
  * @return success (Ok) or an error on failure (usually corrupt data)
  */
-Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t>& compressed_input);
+Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t>& compressed_input,
+                                               const heif_security_limits* limits);

 std::vector<uint8_t> compress_brotli(const uint8_t* input, size_t size);
 #endif
diff --git a/libheif/compression_brotli.cc b/libheif/compression_brotli.cc
index b6abc1fb..6bd4a085 100644
--- a/libheif/compression_brotli.cc
+++ b/libheif/compression_brotli.cc
@@ -31,9 +31,11 @@ const size_t BUF_SIZE = (1 << 18);
 #include <vector>

 #include "error.h"
+#include "security_limits.h"


-Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compressed_input)
+Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compressed_input,
+                                               const heif_security_limits* limits)
 {
     BrotliDecoderResult result = BROTLI_DECODER_RESULT_ERROR;
     std::vector<uint8_t> buffer(BUF_SIZE, 0);
@@ -46,19 +48,32 @@ Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compr

     std::vector<uint8_t> output;

+    // Track the growth of `output` against the security limits. Without this, a
+    // high-ratio "decompression bomb" would expand a few KB of input into GBs of
+    // output, bypassing max_memory_block_size / max_total_memory (GHSA-24wx-9w62-c96w).
+    MemoryHandle output_memory_handle;
+
     while (true)
     {
         result = BrotliDecoderDecompressStream(state.get(), &available_in, &next_in, &available_out, &next_output, 0);

         if (result == BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT)
         {
-            output.insert(output.end(), buffer.data(), buffer.data() + std::distance(buffer.data(), next_output));
+            size_t n_new_bytes = static_cast<size_t>(std::distance(buffer.data(), next_output));
+            if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "brotli decompression output")) {
+                return memErr;
+            }
+            output.insert(output.end(), buffer.data(), buffer.data() + n_new_bytes);
             available_out = buffer.size();
             next_output = buffer.data();
         }
         else if (result == BROTLI_DECODER_RESULT_SUCCESS)
         {
-            output.insert(output.end(), buffer.data(), buffer.data() + std::distance(buffer.data(), next_output));
+            size_t n_new_bytes = static_cast<size_t>(std::distance(buffer.data(), next_output));
+            if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "brotli decompression output")) {
+                return memErr;
+            }
+            output.insert(output.end(), buffer.data(), buffer.data() + n_new_bytes);
             break;
         }
         else if (result == BROTLI_DECODER_RESULT_NEEDS_MORE_INPUT)
diff --git a/libheif/compression_zlib.cc b/libheif/compression_zlib.cc
index e3543d89..2c145540 100644
--- a/libheif/compression_zlib.cc
+++ b/libheif/compression_zlib.cc
@@ -20,6 +20,7 @@


 #include "compression.h"
+#include "security_limits.h"


 #if HAVE_ZLIB
@@ -80,7 +81,8 @@ std::vector<uint8_t> compress(const uint8_t* input, size_t size, int windowSize)
 }


-Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_input, int windowSize)
+Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_input, int windowSize,
+                                        const heif_security_limits* limits)
 {
   if (compressed_input.empty()) {
     return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data,
@@ -89,6 +91,11 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i

   std::vector<uint8_t> output;

+  // Track the growth of `output` against the security limits. Without this, a
+  // high-ratio "decompression bomb" would expand a few KB of input into GBs of
+  // output, bypassing max_memory_block_size / max_total_memory (GHSA-24wx-9w62-c96w).
+  MemoryHandle output_memory_handle;
+
   // decompress data with zlib

   std::vector<uint8_t> dst;
@@ -128,14 +135,18 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i
         break;
       }

-      if (dst.size() >= 256 * 1024 * 1024) { // TODO: make this a security limit
+      // Grow the scratch buffer so inflate() can make progress. Bound its growth
+      // against the per-block security limit so a pathological stream cannot force
+      // an unbounded scratch allocation (the accumulated output is bounded
+      // separately, via output_memory_handle below).
+      size_t new_size = dst.size() * 2;
+      if (limits && limits->max_memory_block_size != 0 && new_size > limits->max_memory_block_size) {
         inflateEnd(&strm);
-        std::stringstream sstr;
-        sstr << "Error performing zlib inflate: maximum output buffer size exceeded\n";
-        return Error(heif_error_Memory_allocation_error, heif_suberror_Compression_initialisation_error, sstr.str());
+        return Error(heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+                     "zlib inflate scratch buffer exceeds the maximum block size");
       }

-      dst.resize(dst.size() * 2);
+      dst.resize(new_size);
       strm.next_out = dst.data();
       strm.avail_out = (uInt)dst.size();
       continue;
@@ -148,8 +159,15 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i
       return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data, sstr.str());
     }

-    // append decoded data to output
-    output.insert(output.end(), dst.begin(), dst.end() - strm.avail_out);
+    // account for and append decoded data to output
+
+    size_t n_new_bytes = dst.size() - strm.avail_out;
+    if (Error memErr = output_memory_handle.alloc(n_new_bytes, limits, "zlib/deflate decompression output")) {
+      inflateEnd(&strm);
+      return memErr;
+    }
+
+    output.insert(output.end(), dst.begin(), dst.begin() + n_new_bytes);
   } while (err != Z_STREAM_END);


@@ -169,13 +187,15 @@ std::vector<uint8_t> compress_deflate(const uint8_t* input, size_t size)
 }


-Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input)
+Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compressed_input,
+                                             const heif_security_limits* limits)
 {
-  return do_inflate(compressed_input, 15);
+  return do_inflate(compressed_input, 15, limits);
 }

-Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input)
+Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
+                                                const heif_security_limits* limits)
 {
-  return do_inflate(compressed_input, -15);
+  return do_inflate(compressed_input, -15, limits);
 }
 #endif
diff --git a/libheif/context.cc b/libheif/context.cc
index aafbcbd9..e4ce85a7 100644
--- a/libheif/context.cc
+++ b/libheif/context.cc
@@ -1111,6 +1111,15 @@ Error HeifContext::interpret_heif_file_images()
     }
     else {
       metadata->m_data = *metadataResult;
+
+      // Account the (possibly decompressed) metadata against the total-memory budget
+      // for the lifetime of the context. This bounds the cumulative memory of up to
+      // max_items metadata items, which would otherwise bypass the security limits
+      // (GHSA-24wx-9w62-c96w).
+      if (Error memErr = metadata->m_memory_handle.alloc(metadata->m_data.size(), &m_limits,
+                                                         "decompressed item metadata")) {
+        return memErr;
+      }
     }

     // --- assign metadata to the image
diff --git a/libheif/file.cc b/libheif/file.cc
index eb90f61e..68eecfd6 100644
--- a/libheif/file.cc
+++ b/libheif/file.cc
@@ -773,7 +773,7 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
           return error;
         }

-        return decompress_zlib(compressed_data);
+        return decompress_zlib(compressed_data, m_limits);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -787,7 +787,7 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
         if (error) {
           return error;
         }
-        return decompress_deflate(compressed_data);
+        return decompress_deflate(compressed_data, m_limits);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -801,7 +801,7 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
         if (error) {
           return error;
         }
-        return decompress_brotli(compressed_data);
+        return decompress_brotli(compressed_data, m_limits);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -997,13 +997,13 @@ Result<std::vector<uint8_t>> HeifFile::get_item_data(heif_item_id ID, heif_metad
   switch (compression) {
 #if HAVE_ZLIB
     case heif_metadata_compression_zlib:
-      return decompress_zlib(compressed_data);
+      return decompress_zlib(compressed_data, m_limits);
     case heif_metadata_compression_deflate:
-      return decompress_deflate(compressed_data);
+      return decompress_deflate(compressed_data, m_limits);
 #endif
 #if HAVE_BROTLI
     case heif_metadata_compression_brotli:
-      return decompress_brotli(compressed_data);
+      return decompress_brotli(compressed_data, m_limits);
 #endif
     default:
       return Error{heif_error_Unsupported_filetype, heif_suberror_Unsupported_header_compression_method};
diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index 5444ad89..be1c6431 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -49,6 +49,14 @@ public:
   std::string content_type;
   std::string item_uri_type;
   std::vector<uint8_t> m_data;
+
+  // Accounts m_data against the context's total-memory budget for the lifetime of
+  // this metadata object. Metadata items may be (brotli/zlib) compressed and are
+  // held until the context is destroyed, so up to max_items of them accumulate.
+  // Without persistent accounting this cumulative decompressed memory bypasses the
+  // security limits (GHSA-24wx-9w62-c96w). MemoryHandle is move-only, which makes
+  // ImageMetadata move-only; it is only ever held via shared_ptr, so that is fine.
+  MemoryHandle m_memory_handle;
 };


diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 0c8d6c5f..80ed92ac 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -74,6 +74,12 @@ add_libheif_test(text)
 add_libheif_test(cxx_wrapper)
 add_libheif_test(component_descriptions)

+if (Brotli_FOUND)
+    add_libheif_test(decompression_bomb)
+else()
+    message(INFO " Disabling the decompression bomb test because Brotli was not found")
+endif ()
+
 if (WITH_OPENJPH_ENCODER AND SUPPORTS_J2K_HT_ENCODING)
     add_libheif_test(encode_htj2k)
 else()
diff --git a/tests/decompression_bomb.cc b/tests/decompression_bomb.cc
new file mode 100644
index 00000000..4b40e7a6
--- /dev/null
+++ b/tests/decompression_bomb.cc
@@ -0,0 +1,270 @@
+/*
+  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-24wx-9w62-c96w: a compressed ('mime', content_encoding
+// "br") metadata item is a decompression bomb. Opening the file decompresses the
+// item in HeifContext::interpret_heif_file_images(), which used to grow an output
+// vector with no size accounting at all, bypassing the configured security limits
+// and allowing OOM from a few hundred bytes of input.
+//
+// The test builds a tiny HEIF whose 'mime' item is a real brotli payload that
+// expands to tens of MB, then opens it once with a tight total-memory limit
+// (expecting a security-limit error) and once with the default limits (expecting
+// success, proving the file itself is valid and it is the limit that is enforced).
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+#include "compression.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace {
+
+void put_u32_be(std::vector<uint8_t>& out, uint32_t v) {
+  out.push_back(static_cast<uint8_t>(v >> 24));
+  out.push_back(static_cast<uint8_t>(v >> 16));
+  out.push_back(static_cast<uint8_t>(v >> 8));
+  out.push_back(static_cast<uint8_t>(v));
+}
+
+void put_u16_be(std::vector<uint8_t>& out, uint16_t v) {
+  out.push_back(static_cast<uint8_t>(v >> 8));
+  out.push_back(static_cast<uint8_t>(v));
+}
+
+void append_fourcc(std::vector<uint8_t>& out, const char fourcc[4]) {
+  out.insert(out.end(), fourcc, fourcc + 4);
+}
+
+// Append a null-terminated string (as read by Box_infe via read_string()).
+void append_cstr(std::vector<uint8_t>& out, const char* s) {
+  while (*s) { out.push_back(static_cast<uint8_t>(*s)); ++s; }
+  out.push_back(0);
+}
+
+void append(std::vector<uint8_t>& out, const std::vector<uint8_t>& v) {
+  out.insert(out.end(), v.begin(), v.end());
+}
+
+std::vector<uint8_t> make_box(const char fourcc[4],
+                              const std::vector<uint8_t>& payload,
+                              bool is_full_box = false,
+                              uint8_t version = 0,
+                              uint32_t flags = 0) {
+  std::vector<uint8_t> body;
+  if (is_full_box) {
+    body.push_back(version);
+    body.push_back(static_cast<uint8_t>(flags >> 16));
+    body.push_back(static_cast<uint8_t>(flags >> 8));
+    body.push_back(static_cast<uint8_t>(flags));
+  }
+  body.insert(body.end(), payload.begin(), payload.end());
+
+  std::vector<uint8_t> box;
+  put_u32_be(box, static_cast<uint32_t>(8 + body.size()));
+  append_fourcc(box, fourcc);
+  box.insert(box.end(), body.begin(), body.end());
+  return box;
+}
+
+// Build a minimal HEIF file with two items:
+//   item 1 ('mski', 8x8, 8bpp): the primary image. The mask codec needs no
+//           bitstream (its "compressed" data is just the raw pixel bytes), so no
+//           codec plugin is required and the file opens without decoding.
+//   item 2 ('mime', content_encoding "br"): a brotli-compressed metadata item,
+//           'cdsc'-referencing item 1, carrying the supplied bomb payload.
+std::vector<uint8_t> build_heif_with_brotli_mime(const std::vector<uint8_t>& brotli_payload) {
+  std::vector<uint8_t> ftyp_payload;
+  append_fourcc(ftyp_payload, "heic");
+  put_u32_be(ftyp_payload, 0);
+  append_fourcc(ftyp_payload, "mif1");
+  append_fourcc(ftyp_payload, "heic");
+  auto ftyp = make_box("ftyp", ftyp_payload);
+
+  std::vector<uint8_t> hdlr_payload;
+  put_u32_be(hdlr_payload, 0);
+  append_fourcc(hdlr_payload, "pict");
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  put_u32_be(hdlr_payload, 0);
+  hdlr_payload.push_back(0);
+  auto hdlr = make_box("hdlr", hdlr_payload, /*full=*/true);
+
+  // pitm: primary item = item 1 (the 'mski' image).
+  std::vector<uint8_t> pitm_payload;
+  put_u16_be(pitm_payload, 1);
+  auto pitm = make_box("pitm", pitm_payload, /*full=*/true);
+
+  // iinf: item 1 = 'mski', item 2 = 'mime'.
+  std::vector<uint8_t> infe1_payload;
+  put_u16_be(infe1_payload, 1);
+  put_u16_be(infe1_payload, 0);
+  append_fourcc(infe1_payload, "mski");
+  infe1_payload.push_back(0); // item_name (empty)
+  auto infe1 = make_box("infe", infe1_payload, /*full=*/true, /*version=*/2);
+
+  std::vector<uint8_t> infe2_payload;
+  put_u16_be(infe2_payload, 2);
+  put_u16_be(infe2_payload, 0);
+  append_fourcc(infe2_payload, "mime");
+  append_cstr(infe2_payload, "");                          // item_name
+  append_cstr(infe2_payload, "application/octet-stream");  // content_type
+  append_cstr(infe2_payload, "br");                        // content_encoding
+  auto infe2 = make_box("infe", infe2_payload, /*full=*/true, /*version=*/2);
+
+  std::vector<uint8_t> iinf_payload;
+  put_u16_be(iinf_payload, 2);
+  append(iinf_payload, infe1);
+  append(iinf_payload, infe2);
+  auto iinf = make_box("iinf", iinf_payload, /*full=*/true);
+
+  // iprp / ipco: prop 1 = ispe(8,8), prop 2 = mskC(8bpp), both for item 1.
+  std::vector<uint8_t> ispe_payload;
+  put_u32_be(ispe_payload, 8);
+  put_u32_be(ispe_payload, 8);
+  auto ispe = make_box("ispe", ispe_payload, /*full=*/true);
+
+  std::vector<uint8_t> mskC_payload;
+  mskC_payload.push_back(8); // bits_per_pixel
+  auto mskC = make_box("mskC", mskC_payload, /*full=*/true);
+
+  std::vector<uint8_t> ipco_payload;
+  append(ipco_payload, ispe);
+  append(ipco_payload, mskC);
+  auto ipco = make_box("ipco", ipco_payload);
+
+  std::vector<uint8_t> ipma_payload;
+  put_u32_be(ipma_payload, 1); // entry_count
+  put_u16_be(ipma_payload, 1); // item_ID 1
+  ipma_payload.push_back(2);   // association_count
+  ipma_payload.push_back(0x80 | 1); // essential, ispe
+  ipma_payload.push_back(0x80 | 2); // essential, mskC
+  auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
+
+  std::vector<uint8_t> iprp_payload;
+  append(iprp_payload, ipco);
+  append(iprp_payload, ipma);
+  auto iprp = make_box("iprp", iprp_payload);
+
+  // idat: 64 bytes of raw 8x8 8bpp mask data, followed by the brotli bomb payload.
+  std::vector<uint8_t> idat_payload(64, 0x7F);
+  const uint32_t bomb_offset = static_cast<uint32_t>(idat_payload.size());
+  const uint32_t bomb_length = static_cast<uint32_t>(brotli_payload.size());
+  idat_payload.insert(idat_payload.end(), brotli_payload.begin(), brotli_payload.end());
+  auto idat = make_box("idat", idat_payload);
+
+  // iloc (version 1): both items store their data in idat (construction_method=1).
+  std::vector<uint8_t> iloc_payload;
+  put_u16_be(iloc_payload, (4 << 12) | (4 << 8) | (0 << 4) | 0); // offset_size=4, length_size=4
+  put_u16_be(iloc_payload, 2);          // item_count
+  // item 1: mask pixels
+  put_u16_be(iloc_payload, 1);          // item_ID
+  put_u16_be(iloc_payload, 0x0001);     // reserved(12) + construction_method=1 (idat)
+  put_u16_be(iloc_payload, 0);          // data_reference_index
+  put_u16_be(iloc_payload, 1);          // extent_count
+  put_u32_be(iloc_payload, 0);          // extent_offset (within idat)
+  put_u32_be(iloc_payload, 64);         // extent_length
+  // item 2: brotli bomb
+  put_u16_be(iloc_payload, 2);          // item_ID
+  put_u16_be(iloc_payload, 0x0001);     // reserved(12) + construction_method=1 (idat)
+  put_u16_be(iloc_payload, 0);          // data_reference_index
+  put_u16_be(iloc_payload, 1);          // extent_count
+  put_u32_be(iloc_payload, bomb_offset);// extent_offset (within idat)
+  put_u32_be(iloc_payload, bomb_length);// extent_length
+  auto iloc = make_box("iloc", iloc_payload, /*full=*/true, /*version=*/1);
+
+  // iref: item 2 ('mime') 'cdsc'-references item 1 (metadata describes the image).
+  std::vector<uint8_t> cdsc_payload;
+  put_u16_be(cdsc_payload, 2);          // from_item_ID
+  put_u16_be(cdsc_payload, 1);          // reference_count
+  put_u16_be(cdsc_payload, 1);          // to_item_ID
+  auto cdsc = make_box("cdsc", cdsc_payload);
+  auto iref = make_box("iref", cdsc, /*full=*/true);
+
+  std::vector<uint8_t> meta_payload;
+  append(meta_payload, hdlr);
+  append(meta_payload, pitm);
+  append(meta_payload, iinf);
+  append(meta_payload, iprp);
+  append(meta_payload, iloc);
+  append(meta_payload, iref);
+  append(meta_payload, idat);
+  auto meta = make_box("meta", meta_payload, /*full=*/true);
+
+  std::vector<uint8_t> file;
+  append(file, ftyp);
+  append(file, meta);
+  return file;
+}
+
+} // namespace
+
+
+TEST_CASE("brotli mime metadata decompression bomb is bounded by security limits") {
+#if HAVE_BROTLI
+  // A brotli payload that expands to ~40 MB but is only a few hundred bytes on disk.
+  const size_t decompressed_size = 40u * 1024 * 1024;
+  std::vector<uint8_t> zeros(decompressed_size, 0);
+  std::vector<uint8_t> bomb = compress_brotli(zeros.data(), zeros.size());
+  REQUIRE(!bomb.empty());
+  REQUIRE(bomb.size() < decompressed_size); // it really is highly compressible
+
+  std::vector<uint8_t> file = build_heif_with_brotli_mime(bomb);
+
+  // --- with a tight total-memory limit, opening must fail rather than allocate the
+  //     whole decompressed payload (previously it ignored the limit entirely).
+  {
+    heif_context* ctx = heif_context_alloc();
+    REQUIRE(ctx != nullptr);
+
+    heif_security_limits* limits = heif_context_get_security_limits(ctx);
+    REQUIRE(limits != nullptr);
+    limits->max_total_memory = 8u * 1024 * 1024; // 8 MB << 40 MB bomb
+
+    heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+    REQUIRE(err.code != heif_error_Ok);
+    REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+
+    heif_context_free(ctx);
+  }
+
+  // --- with the default (generous) limits, the very same file opens successfully,
+  //     proving it is structurally valid and that the limit is what is enforced.
+  {
+    heif_context* ctx = heif_context_alloc();
+    REQUIRE(ctx != nullptr);
+
+    heif_error err = heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr);
+    REQUIRE(err.code == heif_error_Ok);
+
+    heif_context_free(ctx);
+  }
+#else
+  SUCCEED("brotli support not compiled in - skipping decompression bomb test");
+#endif
+}