Commit f8ea4a46 for libheif

commit f8ea4a466922080d45c988f36ef29b3748776e1b
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 25 19:38:33 2026 +0100

    Bound cumulative MIME metadata decompression work [GHSA-c6m8-778m-cf4f]

diff --git a/libheif/compression.h b/libheif/compression.h
index b5a0f993..d4883cec 100644
--- a/libheif/compression.h
+++ b/libheif/compression.h
@@ -23,12 +23,48 @@
 #include <vector>
 #include <cinttypes>
 #include <cstddef>
+#include <mutex>

 #include <error.h>
 #include <libheif/heif_uncompressed.h>

 struct heif_security_limits;

+// Counts bytes produced by MIME metadata decompression across all reads of one
+// file, including unsuccessful and repeated reads. Uncompressed image (unci)
+// decoding does not pass a budget to the decompressors.
+class MetadataDecompressionBudget
+{
+public:
+  void set_limit(const uint64_t* limit) { m_limit = limit; }
+
+  bool consume(size_t bytes)
+  {
+    const uint64_t limit = m_limit ? *m_limit : 0;
+    if (limit == 0 || bytes == 0) { return true; }
+    std::lock_guard<std::mutex> lock(m_mutex);
+    if (m_used >= limit || bytes > limit - m_used) {
+      // Saturate on an over-budget attempt so later reads fail immediately.
+      if (m_used < limit) { m_used = limit; }
+      return false;
+    }
+    m_used += bytes;
+    return true;
+  }
+
+  bool exhausted() const
+  {
+    const uint64_t limit = m_limit ? *m_limit : 0;
+    std::lock_guard<std::mutex> lock(m_mutex);
+    return limit != 0 && m_used >= limit;
+  }
+
+private:
+  const uint64_t* m_limit = nullptr;
+  mutable std::mutex m_mutex;
+  uint64_t m_used = 0;
+};
+
 /**
  * Convert heif_unci_compression enum to a fourcc code.
  *
@@ -88,7 +124,8 @@ 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,
                                              const heif_security_limits* limits,
-                                             uint64_t max_output_size = UINT64_MAX);
+                                             uint64_t max_output_size = UINT64_MAX,
+                                             MetadataDecompressionBudget* work_budget = nullptr);

 /**
  * Decompress "deflate" compressed data.
@@ -107,7 +144,8 @@ Result<std::vector<uint8_t>> decompress_zlib(const std::vector<uint8_t>& compres
  */
 Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
                                                 const heif_security_limits* limits,
-                                                uint64_t max_output_size = UINT64_MAX);
+                                                uint64_t max_output_size = UINT64_MAX,
+                                                MetadataDecompressionBudget* work_budget = nullptr);

 #endif

@@ -126,7 +164,8 @@ Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& comp
  */
 Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t>& compressed_input,
                                                const heif_security_limits* limits,
-                                               uint64_t max_output_size = UINT64_MAX);
+                                               uint64_t max_output_size = UINT64_MAX,
+                                               MetadataDecompressionBudget* work_budget = nullptr);

 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 24795a8b..dc71dbf4 100644
--- a/libheif/compression_brotli.cc
+++ b/libheif/compression_brotli.cc
@@ -36,8 +36,13 @@ const size_t BUF_SIZE = (1 << 18);

 Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compressed_input,
                                                const heif_security_limits* limits,
-                                               uint64_t max_output_size)
+                                               uint64_t max_output_size,
+                                               MetadataDecompressionBudget* work_budget)
 {
+    if (work_budget && work_budget->exhausted()) {
+        return Error(heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+                     "Total metadata decompression budget exceeded");
+    }
     BrotliDecoderResult result = BROTLI_DECODER_RESULT_ERROR;
     std::vector<uint8_t> buffer(BUF_SIZE, 0);
     size_t available_in = compressed_input.size();
@@ -78,6 +83,13 @@ Result<std::vector<uint8_t>> decompress_brotli(const std::vector<uint8_t> &compr
     {
         result = BrotliDecoderDecompressStream(state.get(), &available_in, &next_in, &available_out, &next_output, 0);

+        // Count bytes produced on failed attempts and before output-size checks.
+        size_t produced = static_cast<size_t>(std::distance(buffer.data(), next_output));
+        if (work_budget && !work_budget->consume(produced)) {
+            return Error(heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+                         "Total metadata decompression budget exceeded");
+        }
+
         if (result == BROTLI_DECODER_RESULT_NEEDS_MORE_OUTPUT)
         {
             if (Error err = append_decoded_data()) {
diff --git a/libheif/compression_zlib.cc b/libheif/compression_zlib.cc
index 5eb91709..7fe8d209 100644
--- a/libheif/compression_zlib.cc
+++ b/libheif/compression_zlib.cc
@@ -83,8 +83,13 @@ 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,
                                         const heif_security_limits* limits,
-                                        uint64_t max_output_size)
+                                        uint64_t max_output_size,
+                                        MetadataDecompressionBudget* work_budget)
 {
+  if (work_budget && work_budget->exhausted()) {
+    return Error(heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+                 "Total metadata decompression budget exceeded");
+  }
   if (compressed_input.empty()) {
     return Error(heif_error_Invalid_input, heif_suberror_Decompression_invalid_data,
                  "Empty zlib compressed data.");
@@ -130,6 +135,15 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i

     err = inflate(&strm, Z_NO_FLUSH);

+    // Charge produced bytes even if the stream subsequently fails validation
+    // or the caller rejects the output size.
+    size_t n_new_bytes = dst.size() - strm.avail_out;
+    if (work_budget && !work_budget->consume(n_new_bytes)) {
+      inflateEnd(&strm);
+      return Error(heif_error_Memory_allocation_error, heif_suberror_Security_limit_exceeded,
+                   "Total metadata decompression budget exceeded");
+    }
+
     if (err == Z_BUF_ERROR) {
       if (strm.avail_in == 0) {
         // All input consumed; decompression is complete even without Z_STREAM_END
@@ -162,8 +176,6 @@ Result<std::vector<uint8_t>> do_inflate(const std::vector<uint8_t>& compressed_i

     // account for and append decoded data to output

-    size_t n_new_bytes = dst.size() - strm.avail_out;
-
     // Stop as soon as there is more data than the caller expects. This keeps a
     // decompression bomb from inflating up to the security limits when the size
     // of the decompressed data is known in advance (GHSA-fcmw-5764-7rq8).
@@ -200,15 +212,17 @@ 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,
                                              const heif_security_limits* limits,
-                                             uint64_t max_output_size)
+                                             uint64_t max_output_size,
+                                             MetadataDecompressionBudget* work_budget)
 {
-  return do_inflate(compressed_input, 15, limits, max_output_size);
+  return do_inflate(compressed_input, 15, limits, max_output_size, work_budget);
 }

 Result<std::vector<uint8_t>> decompress_deflate(const std::vector<uint8_t>& compressed_input,
                                                 const heif_security_limits* limits,
-                                                uint64_t max_output_size)
+                                                uint64_t max_output_size,
+                                                MetadataDecompressionBudget* work_budget)
 {
-  return do_inflate(compressed_input, -15, limits, max_output_size);
+  return do_inflate(compressed_input, -15, limits, max_output_size, work_budget);
 }
 #endif
diff --git a/libheif/file.cc b/libheif/file.cc
index 33789f9c..4be75afd 100644
--- a/libheif/file.cc
+++ b/libheif/file.cc
@@ -793,7 +793,8 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
           return error;
         }

-        return decompress_zlib(compressed_data, m_limits, max_output_size);
+        return decompress_zlib(compressed_data, m_limits, max_output_size,
+                               &m_metadata_decompression_budget);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -807,7 +808,8 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
         if (error) {
           return error;
         }
-        return decompress_deflate(compressed_data, m_limits, max_output_size);
+        return decompress_deflate(compressed_data, m_limits, max_output_size,
+                                  &m_metadata_decompression_budget);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -821,7 +823,8 @@ Result<std::vector<uint8_t>> HeifFile::get_uncompressed_item_data(heif_item_id I
         if (error) {
           return error;
         }
-        return decompress_brotli(compressed_data, m_limits, max_output_size);
+        return decompress_brotli(compressed_data, m_limits, max_output_size,
+                                 &m_metadata_decompression_budget);
 #else
         return Error(heif_error_Unsupported_feature,
                      heif_suberror_Unsupported_header_compression_method,
@@ -1000,13 +1003,16 @@ 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, m_limits, max_output_size);
+      return decompress_zlib(compressed_data, m_limits, max_output_size,
+                             &m_metadata_decompression_budget);
     case heif_metadata_compression_deflate:
-      return decompress_deflate(compressed_data, m_limits, max_output_size);
+      return decompress_deflate(compressed_data, m_limits, max_output_size,
+                                &m_metadata_decompression_budget);
 #endif
 #if HAVE_BROTLI
     case heif_metadata_compression_brotli:
-      return decompress_brotli(compressed_data, m_limits, max_output_size);
+      return decompress_brotli(compressed_data, m_limits, max_output_size,
+                               &m_metadata_decompression_budget);
 #endif
     default:
       return Error{heif_error_Unsupported_filetype, heif_suberror_Unsupported_header_compression_method};
diff --git a/libheif/file.h b/libheif/file.h
index a9e23daa..081ae320 100644
--- a/libheif/file.h
+++ b/libheif/file.h
@@ -29,6 +29,7 @@
 #include "image-items/vvc.h"
 //#include "codecs/uncompressed/unc_boxes.h"
 #include "file_layout.h"
+#include "compression.h"

 #include <map>
 #include <memory>
@@ -66,7 +67,10 @@ public:

   // The limits will be shared from the HeifContext limits.
   // You have to make sure that the pointer points to a valid object as long as the HeifFile is used.
-  void set_security_limits(const heif_security_limits* limits) { m_limits = limits; }
+  void set_security_limits(const heif_security_limits* limits) {
+    m_limits = limits;
+    m_metadata_decompression_budget.set_limit(limits ? &limits->max_total_memory : nullptr);
+  }

   const heif_security_limits* get_security_limits() const { return m_limits; }

@@ -308,6 +312,7 @@ private:
   std::shared_ptr<Box_mvhd> m_mvhd_box;

   const heif_security_limits* m_limits = nullptr;
+  mutable MetadataDecompressionBudget m_metadata_decompression_budget;
   IDCreator m_id_creator;

   Error parse_heif_file();
diff --git a/tests/decompression_bomb.cc b/tests/decompression_bomb.cc
index 5e458d12..ad112c74 100644
--- a/tests/decompression_bomb.cc
+++ b/tests/decompression_bomb.cc
@@ -33,6 +33,7 @@

 #include "catch_amalgamated.hpp"
 #include "libheif/heif.h"
+#include "libheif/heif_items.h"
 #include "test_utils.h"
 #include "compression.h"

@@ -202,6 +203,38 @@ TEST_CASE("unreferenced compressed mime item is not decompressed during file ope
 #endif
 }

+
+TEST_CASE("metadata decompression work is cumulative across item reads") {
+#if HAVE_BROTLI
+  const size_t output_size = 1024 * 1024;
+  std::vector<uint8_t> zeros(output_size, 0);
+  auto compressed = compress_brotli(zeros.data(), zeros.size());
+  auto file = build_heif_with_brotli_mime(compressed, false);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+  auto* limits = heif_context_get_security_limits(ctx);
+  limits->max_total_memory = output_size + output_size / 2;
+  REQUIRE(heif_context_read_from_memory_without_copy(ctx, file.data(), file.size(), nullptr).code == heif_error_Ok);
+
+  uint8_t* item_data = nullptr;
+  size_t item_size = 0;
+  heif_metadata_compression method;
+  REQUIRE(heif_item_get_item_data(ctx, 2, &method, &item_data, &item_size).code == heif_error_Ok);
+  REQUIRE(method == heif_metadata_compression_brotli);
+  heif_release_item_data(ctx, &item_data);
+
+  REQUIRE(heif_item_get_item_data(ctx, 2, nullptr, &item_data, &item_size).code == heif_error_Ok);
+  REQUIRE(item_size == output_size);
+  heif_release_item_data(ctx, &item_data);
+
+  auto err = heif_item_get_item_data(ctx, 2, nullptr, &item_data, &item_size);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+  REQUIRE(item_data == nullptr);
+  heif_context_free(ctx);
+#endif
+}
+
 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.