Commit 4be53862 for libheif

commit 4be53862a558726a132c1ac4a17ead273115bf19
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 5 14:42:09 2026 +0200

    Do not reject reference cycles at file load; catch them per item at decode

    A reference cycle only matters when the cyclic item is decoded, and it is now
    caught there by ImageItem::verify_decodable() (GHSA-prgh-72vc-3xmc), per item
    and following both the 'dimg' and 'auxl' edges. The parse-time
    HeifFile::check_for_ref_cycle rejected the whole file at load whenever the
    primary item's 'dimg' subtree contained a cycle, which also made the file's
    other, independently valid items undecodable.

    Remove that load-time check. A file with a cyclic reference now opens, its valid
    items decode, and only the cyclic item fails, at decode. This also makes the
    behavior uniform: a cyclic subtree under a non-primary item already loaded and
    failed only at decode, and a primary-rooted cycle now behaves the same way. No
    other load-time traversal recurses over the derived-image graph (the remaining
    'dimg' lookups are single-level), so nothing else relied on this rejection.

    Adds a regression test: a file whose primary is a cyclic grid still loads and
    its unrelated valid mask item decodes, while decoding the cyclic item returns
    the reference-cycle error.

diff --git a/libheif/file.cc b/libheif/file.cc
index 5263d8fc..725aef20 100644
--- a/libheif/file.cc
+++ b/libheif/file.cc
@@ -624,12 +624,11 @@ Error HeifFile::parse_heif_images()
   m_idat_box = m_meta_box->get_child_box<Box_idat>();

   m_iref_box = m_meta_box->get_child_box<Box_iref>();
-  if (m_iref_box && m_pitm_box) {
-    Error error = check_for_ref_cycle(get_primary_image_ID(), m_iref_box);
-    if (error) {
-      return error;
-    }
-  }
+
+  // Note: reference cycles are not rejected at load. A cycle only matters when
+  // it is decoded, and it is caught there per item by ImageItem::verify_decodable()
+  // (following both 'dimg' and 'auxl' edges). Rejecting the whole file at load
+  // would also make its unaffected, independently valid items undecodable.

   m_grpl_box = m_meta_box->get_child_box<Box_grpl>();

@@ -651,51 +650,6 @@ Error HeifFile::parse_heif_sequences()
 }


-Error HeifFile::check_for_ref_cycle(heif_item_id ID,
-                                    const std::shared_ptr<Box_iref>& iref_box) const
-{
-  std::unordered_set<heif_item_id> parent_items;    // items on the current DFS path
-  std::unordered_set<heif_item_id> finished_items;  // items whose subtree is known acyclic
-  return check_for_ref_cycle_recursion(ID, iref_box, parent_items, finished_items);
-}
-
-
-Error HeifFile::check_for_ref_cycle_recursion(heif_item_id ID,
-                                    const std::shared_ptr<Box_iref>& iref_box,
-                                    std::unordered_set<heif_item_id>& parent_items,
-                                    std::unordered_set<heif_item_id>& finished_items) const {
-  if (parent_items.find(ID) != parent_items.end()) {
-    return Error(heif_error_Invalid_input,
-                 heif_suberror_Item_reference_cycle,
-                 "Image reference cycle");
-  }
-
-  // An item whose subtree we have already fully verified as acyclic cannot be
-  // part of a cycle when reached again through a different path. Without this
-  // memo the DFS visits every distinct root-to-item path, which is exponential
-  // for shared/nested derived-image references (e.g. many 'iden' items pointing
-  // at a common base), turning a tiny file into a file-open CPU DoS.
-  // (GHSA-x8xm-cm2c-cfc8)
-  if (finished_items.find(ID) != finished_items.end()) {
-    return Error::Ok;
-  }
-
-  parent_items.insert(ID);
-
-  std::vector<heif_item_id> image_references = iref_box->get_references(ID, fourcc("dimg"));
-  for (heif_item_id reference_idx : image_references) {
-    Error error = check_for_ref_cycle_recursion(reference_idx, iref_box, parent_items, finished_items);
-    if (error) {
-      return error;
-    }
-  }
-
-  parent_items.erase(ID);
-  finished_items.insert(ID);
-  return Error::Ok;
-}
-
-
 bool HeifFile::item_exists(heif_item_id ID) const
 {
   auto image_iter = m_infe_boxes.find(ID);
diff --git a/libheif/file.h b/libheif/file.h
index b7e0c966..247778c2 100644
--- a/libheif/file.h
+++ b/libheif/file.h
@@ -318,14 +318,6 @@ private:
   Error parse_heif_images();

   Error parse_heif_sequences();
-
-  Error check_for_ref_cycle(heif_item_id ID,
-                            const std::shared_ptr<Box_iref>& iref_box) const;
-
-  Error check_for_ref_cycle_recursion(heif_item_id ID,
-                                      const std::shared_ptr<Box_iref>& iref_box,
-                                      std::unordered_set<heif_item_id>& parent_items,
-                                      std::unordered_set<heif_item_id>& finished_items) const;
 };

 #endif
diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index 4ede1466..34590736 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -895,7 +895,8 @@ namespace {
 // holds the items on the current root-to-node path (a repeat is a cycle);
 // `verified` memoizes items whose subtree is already known acyclic, so a shared
 // sub-image reached through several paths is visited once and the walk stays
-// linear (cf. HeifFile::check_for_ref_cycle, GHSA-x8xm-cm2c-cfc8).
+// linear rather than exponential in the number of root-to-item paths
+// (cf. the decode amplification bound, GHSA-x8xm-cm2c-cfc8).
 Error check_decode_reference_cycles(const ImageItem* item,
                                     std::set<heif_item_id>& on_path,
                                     std::set<heif_item_id>& verified)
@@ -961,6 +962,14 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
   // The matching insert lives inside decode_compressed_image() of derived
   // items (grid/overlay/iden), so the current item is in decode_state only
   // when called from one of its own descendants.
+  //
+  // Second-layer hardening, not required for correctness: the top-level decode
+  // already ran ImageItem::verify_decodable() (HeifContext::decode_image), which
+  // proves the whole reachable decode graph is acyclic before any recursion, so
+  // this per-path check can never fire on a graph that reached here. It is kept
+  // as a cheap in-decode backstop, and the same applies to the equivalent checks
+  // in decode_compressed_image() and in grid/overlay/iden. We may remove them in
+  // the future once verify_decodable() is the sole cycle guard.
   if (decode_state.processed_ids.contains(m_id)) {
     return Error{heif_error_Invalid_input,
                  heif_suberror_Unspecified,
diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index 533f5a31..9fd58550 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -376,10 +376,11 @@ public:
   // the graph of items reached by the decode recursion (derived-image 'dimg'
   // inputs and the alpha 'auxl' auxiliary) must be acyclic. A reference cycle
   // would otherwise let two parallel grid-tile workers take two item mutexes in
-  // opposite order and deadlock (GHSA-prgh-72vc-3xmc); the parse-time check only
-  // follows 'dimg' from the primary item and so misses the alpha edge and any
-  // non-primary subtree. Called once per top-level decode in
-  // HeifContext::decode_image(). This is the single place to add further
+  // opposite order and deadlock (GHSA-prgh-72vc-3xmc). Cycles are not rejected
+  // at file load, so that a file's independently valid items stay decodable;
+  // this per-item decode-time check is what makes a cyclic item safe. Called
+  // once per top-level decode in HeifContext::decode_image(). This is the single
+  // place to add further
   // decodability constraints (e.g. no alpha auxiliary on an alpha image, MIAF
   // derivation-chain limits).
   Error verify_decodable() const;
diff --git a/tests/parallel_grid_deadlock.cc b/tests/parallel_grid_deadlock.cc
index dd37566d..cf78397c 100644
--- a/tests/parallel_grid_deadlock.cc
+++ b/tests/parallel_grid_deadlock.cc
@@ -394,3 +394,33 @@ TEST_CASE("parallel grid: a normal two-tile grid still decodes") {
   REQUIRE(completed);
   REQUIRE(err.code == heif_error_Ok);
 }
+
+// A reference cycle is not rejected at file load: the file still opens and its
+// independently valid items still decode. Only the cyclic item itself fails,
+// at decode time. Here the primary (item 1) is a grid whose two sub-grids form
+// a 'dimg' cycle, while item 4 is an unrelated valid mask.
+TEST_CASE("parallel grid: a cyclic primary does not make valid sibling items undecodable") {
+  const std::vector<uint8_t> big(32 * 64, 0x7F);
+  const std::vector<uint8_t> small(32 * 32, 0x7F);
+  std::vector<Item> items;
+  //             id  type    w   h   alpha dimg    auxl data
+  items.push_back({1, "grid", 64, 64, false, {2, 3}, {}, image_grid(1, 2, 64, 64)});  // cyclic primary
+  items.push_back({2, "grid", 32, 64, false, {3},    {}, image_grid(1, 1, 32, 64)});  // G1 -> G2
+  items.push_back({3, "grid", 32, 64, false, {2},    {}, image_grid(1, 1, 32, 64)});  // G2 -> G1
+  items.push_back({4, "mski", 32, 32, false, {},     {}, small});                      // valid, unrelated
+
+  auto data = build_file(items, /*primary=*/1);
+
+  // The valid sibling decodes. Because decode reads the file first, this also
+  // proves the file loaded despite the cyclic primary.
+  heif_error err_valid{};
+  bool completed_valid = decode_item_with_timeout(data, /*item=*/4, std::chrono::seconds(20), err_valid);
+  REQUIRE(completed_valid);
+  REQUIRE(err_valid.code == heif_error_Ok);
+
+  // The cyclic item is still rejected, at decode.
+  heif_error err_cyclic{};
+  bool completed_cyclic = decode_item_with_timeout(data, /*item=*/1, std::chrono::seconds(20), err_cyclic);
+  REQUIRE(completed_cyclic);
+  REQUIRE(err_cyclic.code == heif_error_Invalid_input);
+}