Commit 748ee134 for libheif

commit 748ee134c75b467cee61128e09a7c025c6953fc8
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sun Oct 25 23:58:53 2026 +0100

    Bound total decoded pixels of derived images [GHSA-48p4-gj45-w9v6]

    The decode budget of GHSA-x8xm-cm2c-cfc8 counts item decodes only, but
    each decode may produce an image of up to max_image_size_pixels, however
    little of it the referencing item uses. A grid of 660 1x1 tiles, each a
    'clap' crop of an 8192x8192 overlay canvas, built the canvas 660 times
    (26 s of CPU for a 2.6 kB file). Tiles outside of the grid image, nested
    overlays that list a large canvas repeatedly, and oversized alpha images
    of grid tiles had the same effect.

    - Add a pixel budget per top-level decode (DecodePixelBudget), checked in
      ImageItem::decode_image() before the item is decoded, with the size it
      is about to produce (ispe, or the grid/overlay header size). The
      allowance is 2x the summed size of the distinct items in the decode,
      at least 2048x2048 and at most max_image_size_pixels.
    - For a single-tile decode, each item counts with at most the tile size
      and the minimum is divided among the tiles, so that decoding all tiles
      one by one costs no more than decoding the whole image.
    - An exceeded security limit in a grid tile is an error also in
      non-strict decoding instead of a blank tile.
    - Do not decode grid tiles that lie completely outside of the image.

    Discovered by Anthropic's OSS Scanner, as vulnerability ANT-2026-XHT9G07S.

diff --git a/libheif/context.cc b/libheif/context.cc
index 434195c3..be37a1b7 100644
--- a/libheif/context.cc
+++ b/libheif/context.cc
@@ -1502,6 +1502,28 @@ Result<std::shared_ptr<HeifPixelImage>> HeifContext::decode_image(heif_item_id I
     decode_state.max_overlay_nesting = MAX_OVERLAY_NESTING_LEVEL;
     decode_state.max_transformations = MAX_TRANSFORMATIONS_PER_DECODE_PATH;
     decode_state.decode_count = std::make_shared<std::atomic<uint32_t>>(0);
+
+    // Also bound the total size of the images decoded, because the decode operations
+    // are not equally expensive (a 'clap' can keep one pixel of a maximum-size image).
+    if (limits->max_image_size_pixels != 0) {
+      // A single tile only gets its share of the budget of the whole image. Otherwise,
+      // decoding all tiles one by one would cost the number of tiles times that budget.
+      // The tile position refers to this tiling (see ImageItem::decode_image()).
+      heif_image_tiling tiling{};
+      if (decode_only_tile) {
+        tiling = imgitem->get_image_tiling_with_alpha();
+      }
+
+      const uint64_t num_tiles = static_cast<uint64_t>(tiling.num_columns) * tiling.num_rows;
+      if (num_tiles > 1) {
+        decode_state.pixel_budget = std::make_shared<DecodePixelBudget>(limits->max_image_size_pixels,
+                                                                        static_cast<uint64_t>(tiling.tile_width) * tiling.tile_height,
+                                                                        num_tiles);
+      }
+      else {
+        decode_state.pixel_budget = std::make_shared<DecodePixelBudget>(limits->max_image_size_pixels);
+      }
+    }
   }

   auto decodingResult = imgitem->decode_image(options, decode_only_tile, tx, ty, decode_state);
diff --git a/libheif/image-items/grid.cc b/libheif/image-items/grid.cc
index 1f0ecc54..9815c471 100644
--- a/libheif/image-items/grid.cc
+++ b/libheif/image-items/grid.cc
@@ -514,6 +514,13 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t
   static std::mutex warningsMutex;
 #endif

+  // The grid may have more rows or columns of tiles than the output image needs. A tile
+  // that lies completely outside of the image has nothing to contribute. It is not decoded,
+  // because each surplus tile could otherwise cost a full decode of a large tile item.
+  if (x0 >= get_grid_spec().get_width() || y0 >= get_grid_spec().get_height()) {
+    return progress_and_return_ok(options, progress_counter);
+  }
+
   auto tileItem = get_context()->get_image(tileID, true);
   if (!tileItem && !options.strict_decoding) {
     // We ignore missing images. The un-pasted canvas region stays zero from calloc().
@@ -535,7 +542,11 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t

   auto decodeResult = tileItem->decode_image(options, false, 0, 0, std::move(decode_state));
   if (!decodeResult) {
-    if (!options.strict_decoding) {
+    // An exceeded security limit is not a broken tile. When the decode budget of the whole
+    // image is used up, every further tile fails as well, and returning the image with
+    // these tiles left blank would hide that the file exceeds the limits.
+    if (!options.strict_decoding &&
+        decodeResult.error().sub_error_code != heif_suberror_Security_limit_exceeded) {
       // We ignore broken tiles. The un-pasted canvas region stays zero from calloc().
 #if ENABLE_PARALLEL_TILE_DECODING
       std::lock_guard<std::mutex> lock(warningsMutex);
@@ -626,12 +637,6 @@ Error ImageItem_Grid::decode_and_paste_tile_image(heif_item_id tileID, uint32_t
             "Image tile has different chroma format than combined image"};
   }

-  // The grid may have more rows or columns of tiles than the output image needs. A tile
-  // that lies completely outside of the image has nothing to contribute.
-  if (x0 >= inout_image->get_width() || y0 >= inout_image->get_height()) {
-    return progress_and_return_ok(options, progress_counter);
-  }
-
   // copy_image_to() refuses a tile whose planes have another bit depth than the canvas.
   // Its error used to be discarded: such a tile was left out, its area stayed zero, and
   // decoding reported success (GHSA-vv35-6hxg-95x8). check_tile_format() above finds such
@@ -799,6 +804,13 @@ void ImageItem_Grid::get_tile_size(uint32_t& w, uint32_t& h) const
 }


+void ImageItem_Grid::get_coded_image_size(uint32_t& w, uint32_t& h) const
+{
+  w = get_grid_spec().get_width();
+  h = get_grid_spec().get_height();
+}
+
+

 int ImageItem_Grid::get_luma_bits_per_pixel_unlocked() const
 {
diff --git a/libheif/image-items/grid.h b/libheif/image-items/grid.h
index 4890e12b..11250a00 100644
--- a/libheif/image-items/grid.h
+++ b/libheif/image-items/grid.h
@@ -158,6 +158,8 @@ public:

   void get_tile_size(uint32_t& w, uint32_t& h) const override;

+  void get_coded_image_size(uint32_t& w, uint32_t& h) const override;
+
 private:
   int get_luma_bits_per_pixel_unlocked() const override;

diff --git a/libheif/image-items/image_item.cc b/libheif/image-items/image_item.cc
index 9b529644..371dcc26 100644
--- a/libheif/image-items/image_item.cc
+++ b/libheif/image-items/image_item.cc
@@ -533,6 +533,13 @@ void ImageItem::get_tile_size(uint32_t& w, uint32_t& h) const
 }


+void ImageItem::get_coded_image_size(uint32_t& w, uint32_t& h) const
+{
+  w = get_ispe_width();
+  h = get_ispe_height();
+}
+
+
 Error ImageItem::postprocess_coded_image_colorspace(heif_colorspace* inout_colorspace, heif_chroma* inout_chroma) const
 {
 #if 0
@@ -1262,6 +1269,32 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
   }


+  // --- bound the total size of the images decoded for this top-level decode
+
+  // The decode operations counted above are not equally expensive, and what the
+  // referencing item uses of the decoded image may be much smaller than the image
+  // (e.g. a 'clap' crop of a large overlay canvas, used as a 1x1 grid tile). This has
+  // to be checked with the size the item is about to produce, before the decoding,
+  // because a failed grid tile is only a warning in non-strict mode.
+
+  uint32_t coded_width, coded_height;
+  if (decode_coded_tile_only) {
+    get_tile_size(coded_width, coded_height);
+  }
+  else {
+    get_coded_image_size(coded_width, coded_height);
+  }
+
+  const uint64_t num_coded_pixels = static_cast<uint64_t>(coded_width) * coded_height;
+
+  if (decode_state.pixel_budget &&
+      !decode_state.pixel_budget->count_item_decode(m_id, num_coded_pixels)) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Security_limit_exceeded,
+                 "Too many pixels decoded for derived images (possible reference amplification)"};
+  }
+
+
   // --- transform tile position

   // The alpha image gets the requested tile position and converts it according to
@@ -1346,6 +1379,14 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem::decode_image(const heif_decod
                  "Decoding returned no image");
   }

+  // Without an 'ispe', the size of the coded image is only known now.
+  if (decode_state.pixel_budget && num_coded_pixels == 0 &&
+      !decode_state.pixel_budget->count_item_decode(m_id, static_cast<uint64_t>(img->get_width()) * img->get_height())) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Security_limit_exceeded,
+                 "Too many pixels decoded for derived images (possible reference amplification)"};
+  }
+
   // --- validate the decoded image against the signaled size (pre-transform)

   if (Error err = check_decoded_image_size(*img, decode_coded_tile_only, tile_x0, tile_y0)) {
diff --git a/libheif/image-items/image_item.h b/libheif/image-items/image_item.h
index 48963d6e..908ed328 100644
--- a/libheif/image-items/image_item.h
+++ b/libheif/image-items/image_item.h
@@ -223,6 +223,12 @@ public:

   virtual void get_tile_size(uint32_t& w, uint32_t& h) const;

+  // The size of the image that decode_compressed_image() builds when it decodes the whole
+  // image (before the image transformations). The size is 0 when it is unknown.
+  // Default behavior: the 'ispe' size. Derived images that take the size of their canvas
+  // from their own header return that one.
+  virtual void get_coded_image_size(uint32_t& w, uint32_t& h) const;
+
   virtual Error get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const;

   Error postprocess_coded_image_colorspace(heif_colorspace* inout_colorspace, heif_chroma* inout_chroma) const;
diff --git a/libheif/image-items/overlay.cc b/libheif/image-items/overlay.cc
index dda169ef..c30ac3d8 100644
--- a/libheif/image-items/overlay.cc
+++ b/libheif/image-items/overlay.cc
@@ -293,6 +293,13 @@ Result<std::shared_ptr<HeifPixelImage>> ImageItem_Overlay::decode_compressed_ima
   return decode_overlay_image(options, decode_state);
 }

+
+void ImageItem_Overlay::get_coded_image_size(uint32_t& w, uint32_t& h) const
+{
+  w = m_overlay_spec.get_canvas_width();
+  h = m_overlay_spec.get_canvas_height();
+}
+
 // Note: ImageItem_Overlay does not override check_decoded_image_size(). The overlay
 // canvas is built to the overlay-header size by construction (decode_overlay_image
 // creates it at m_overlay_spec canvas size), so checking it against that same size
diff --git a/libheif/image-items/overlay.h b/libheif/image-items/overlay.h
index 43013e18..fa6e3ab8 100644
--- a/libheif/image-items/overlay.h
+++ b/libheif/image-items/overlay.h
@@ -125,6 +125,8 @@ public:
                                                                   bool decode_tile_only, uint32_t tile_x0, uint32_t tile_y0,
                                                                   DecodeTraversalState decode_state) const override;

+  void get_coded_image_size(uint32_t& w, uint32_t& h) const override;
+

   // --- iovl specific

diff --git a/libheif/security_limits.cc b/libheif/security_limits.cc
index 5ea231d3..1bbd7146 100644
--- a/libheif/security_limits.cc
+++ b/libheif/security_limits.cc
@@ -129,6 +129,59 @@ heif_security_limits tighten_image_size_limit_for_ispe(const heif_security_limit
 }


+DecodePixelBudget::DecodePixelBudget(uint64_t max_image_size_pixels)
+{
+  // Clamp a very high configured limit so that the sums below cannot overflow.
+  const uint64_t max_pixels = uint64_t{1} << 40;
+
+  m_max_distinct_item_pixels = std::min(max_image_size_pixels, max_pixels);
+  m_max_item_pixels = m_max_distinct_item_pixels;
+  m_min_distinct_item_pixels = MIN_DECODED_PIXELS_BASE;
+}
+
+
+DecodePixelBudget::DecodePixelBudget(uint64_t max_image_size_pixels, uint64_t tile_pixels, uint64_t num_tiles)
+    : DecodePixelBudget(max_image_size_pixels)
+{
+  // Each tile gets its share of the budget of the whole image.
+  m_max_item_pixels = std::min(tile_pixels, m_max_distinct_item_pixels);
+
+  if (num_tiles > 1) {
+    m_min_distinct_item_pixels = (MIN_DECODED_PIXELS_BASE + num_tiles - 1) / num_tiles;
+  }
+}
+
+
+bool DecodePixelBudget::count_item_decode(heif_item_id id, uint64_t num_pixels)
+{
+  std::lock_guard<std::mutex> lock(m_mutex);
+
+  // A larger image is going to be refused by the image size check.
+  num_pixels = std::min(num_pixels, m_max_distinct_item_pixels);
+
+  // Each distinct item adds its size to m_distinct_item_pixels only once. The sum saturates at the
+  // maximum image size: many large items, each cropped to a small image, must not
+  // raise the allowance as fast as they use it up. For a tile decode, an item adds at most
+  // the size of a tile.
+  const uint64_t item_contribution = std::min(num_pixels, m_max_item_pixels);
+  uint64_t& item_pixels = m_item_pixels_counted[id];
+  if (item_contribution > item_pixels) {
+    m_distinct_item_pixels = std::min(m_distinct_item_pixels + (item_contribution - item_pixels), m_max_distinct_item_pixels);
+    item_pixels = item_contribution;
+  }
+
+  // Every decode is consumed, also repeated decodes of the same item.
+  // Compared by subtraction, so that the check itself cannot overflow.
+  uint64_t allowance = std::max(m_distinct_item_pixels, m_min_distinct_item_pixels) * MAX_DECODED_PIXELS_FACTOR;
+  if (m_consumed_pixels > allowance || num_pixels > allowance - m_consumed_pixels) {
+    return false;
+  }
+
+  m_consumed_pixels += num_pixels;
+  return true;
+}
+
+
 Error check_for_valid_image_size(const heif_security_limits* limits, uint32_t width, uint32_t height)
 {
   uint64_t maximum_image_size_limit = limits->max_image_size_pixels;
diff --git a/libheif/security_limits.h b/libheif/security_limits.h
index 611153d5..f41a37ae 100644
--- a/libheif/security_limits.h
+++ b/libheif/security_limits.h
@@ -24,8 +24,10 @@
 #include <cinttypes>
 #include <cstddef>
 #include <set>
+#include <map>
 #include <atomic>
 #include <memory>
+#include <mutex>
 #include "error.h"


@@ -73,6 +75,77 @@ static const uint32_t MAX_DERIVED_IMAGE_DECODE_FACTOR = 2;
 // items, so this bounds the total amount of repeated pixel processing.
 static const uint32_t MAX_TRANSFORMATIONS_PER_DECODE_PATH = 32;

+// How often the pixels of the items that take part in a single top-level decode
+// may be decoded on average. The number of decode operations alone does not
+// bound the work: an item that is referenced many times is decoded again for
+// each reference, and what a reference uses of it can be much smaller than the
+// item (e.g. a 'clap' crop). A grid of 660 1x1 tiles, each the same 'clap' of a
+// 16384x16384 overlay canvas, stays within the decode-operation budget but builds
+// the canvas 660 times. A well-formed file decodes each item a small number of
+// times, so the total size of the decoded images is capped at a modest multiple
+// of the summed size of the items taking part. A grid that uses one tile item for
+// many positions is covered by the size of the grid itself.
+// In a usual file, grid tiles are distinct items and overlays do not reuse their inputs,
+// so each item is decoded once (factor 1). A grid that repeats one tile item for all
+// of its positions comes close to 2.
+static const uint32_t MAX_DECODED_PIXELS_FACTOR = 2;
+
+// Small items are cheap to decode however often they are referenced (the number of
+// decode operations is bounded separately). The factor above is applied to at least
+// this number of pixels, so that it only restricts decodes that take noticeable time.
+static const uint64_t MIN_DECODED_PIXELS_BASE = 2048 * 2048;
+
+
+// Pixel budget of one top-level decode, shared by all copies of its DecodeTraversalState.
+// Each decode of an item uses up the size of the item. The budget is MAX_DECODED_PIXELS_FACTOR
+// times the summed size of the distinct items that take part in the decode. As many large
+// items can be cropped to a small image just like a single one that is used many times,
+// that sum counts only up to max_image_size_pixels.
+//
+// When a single tile of a tiled image is decoded (heif_image_handle_decode_image_tile()),
+// each call is a top-level decode of its own, so a budget computed as above would allow
+// each tile as much as the whole image. Decoding all tiles would then cost the number of
+// tiles times the budget of the whole image. For a tile decode, each item therefore adds
+// at most the size of one tile to the summed size of the distinct items, and the minimum
+// MIN_DECODED_PIXELS_BASE is divided among the tiles. A tile item that is much larger than
+// its tile (e.g. a 'clap' crop of a large image) is then refused, also when it is reached
+// through 'iden' items.
+class DecodePixelBudget
+{
+public:
+  // max_image_size_pixels is the size limit for a single image from the context's
+  // heif_security_limits. It caps the summed size of the distinct items, so
+  // the total budget is at most MAX_DECODED_PIXELS_FACTOR times this limit.
+  explicit DecodePixelBudget(uint64_t max_image_size_pixels);
+
+  // Budget for decoding one tile of an image that consists of num_tiles tiles of
+  // tile_pixels each. Use the constructor above for an image that is a single tile.
+  DecodePixelBudget(uint64_t max_image_size_pixels, uint64_t tile_pixels, uint64_t num_tiles);
+
+  // Count one decode of an item against the budget. Every call adds num_pixels to the
+  // pixels decoded so far. The item id is used for the allowance: each distinct item adds
+  // its (largest) size to it only once, however often it is decoded. A file that decodes
+  // each item about once stays far below the budget; one that decodes the same items again
+  // and again uses it up.
+  // To be called before the item is decoded, with the size of the image (or tile) that is
+  // going to be decoded. When that size is not known (0), it has to be called again after
+  // the decoding with the size of the decoded image.
+  // Returns false when the budget has been exhausted.
+  bool count_item_decode(heif_item_id id, uint64_t num_pixels);
+
+private:
+  std::mutex m_mutex;
+
+  // The allowance is MAX_DECODED_PIXELS_FACTOR * max(m_distinct_item_pixels, m_min_distinct_item_pixels).
+  uint64_t m_max_distinct_item_pixels;                    // cap of m_distinct_item_pixels and of a single decode
+  uint64_t m_max_item_pixels;                             // cap of what one item adds to m_distinct_item_pixels
+  uint64_t m_min_distinct_item_pixels;                    // floor of m_distinct_item_pixels in the allowance
+  std::map<heif_item_id, uint64_t> m_item_pixels_counted; // what each item has added to m_distinct_item_pixels
+  uint64_t m_distinct_item_pixels = 0;                    // summed (largest) sizes of the distinct items
+
+  uint64_t m_consumed_pixels = 0;                         // summed sizes of all decodes so far
+};
+

 // Traversal state threaded through the derived-image decode recursion for one
 // top-level decode. It is copied by value at every hop, which gives the two
@@ -86,7 +159,8 @@ static const uint32_t MAX_TRANSFORMATIONS_PER_DECODE_PATH = 32;
 //
 //   - decode_count is a shared_ptr to an atomic, so every copy (including the
 //     copies handed to parallel tile-decode threads) shares one global counter
-//     that bounds the total number of decode operations.
+//     that bounds the total number of decode operations. pixel_budget is shared
+//     in the same way and bounds the total size of what these operations decode.
 //
 // A default-constructed DecodeTraversalState (max_decodes == 0) imposes no limit; the
 // budget is seeded once at the top level in HeifContext::decode_image().
@@ -103,6 +177,8 @@ struct DecodeTraversalState
   std::shared_ptr<std::atomic<uint32_t>> decode_count;
   uint32_t max_decodes = 0;             // 0 == unlimited

+  std::shared_ptr<DecodePixelBudget> pixel_budget;  // null == unlimited
+
   // Count one sub-image decode against the shared budget.
   // Returns false when the budget has been exhausted.
   bool count_decode()
diff --git a/tests/overlay_amplification.cc b/tests/overlay_amplification.cc
index f56e6a8a..9b70684d 100644
--- a/tests/overlay_amplification.cc
+++ b/tests/overlay_amplification.cc
@@ -33,13 +33,19 @@
 // via nested overlays). These tests build the amplification structures out of
 // 'iovl' overlays over an 'mski' base (which needs no codec plugin) and check
 // that decoding is rejected quickly instead of blowing up.
+//
+// The number of decodes alone does not bound the work either: what a reference
+// uses of the decoded image can be much smaller than the image (a 'clap' crop).
+// The last tests check that the total size of the decoded images is bounded, too.

 #include "catch_amalgamated.hpp"
 #include "libheif/heif.h"
 #include "test_utils.h"

 #include <cstdint>
+#include <map>
 #include <string>
+#include <utility>
 #include <vector>

 namespace {
@@ -61,19 +67,32 @@ std::vector<uint8_t> make_overlay_spec(uint16_t canvas_w, uint16_t canvas_h, siz
   return s;
 }

+// ImageGrid payload (ISO/IEC 23008-12) with 16-bit fields.
+std::vector<uint8_t> make_grid_spec(uint8_t rows, uint8_t columns, uint16_t width, uint16_t height) {
+  std::vector<uint8_t> s;
+  s.push_back(0);           // version
+  s.push_back(0);           // flags (16-bit fields)
+  s.push_back(static_cast<uint8_t>(rows - 1));
+  s.push_back(static_cast<uint8_t>(columns - 1));
+  put_u16_be(s, width);
+  put_u16_be(s, height);
+  return s;
+}
+
 struct Item {
   uint16_t id = 0;
-  std::string type;                 // "mski", "iovl", "iden"
-  std::vector<uint16_t> dimg;       // 'dimg' references (iovl/iden)
-  std::vector<uint8_t> data;        // idat payload (mski pixels / iovl spec); empty for iden
+  std::string type;                 // "mski", "iovl", "iden", "grid"
+  std::vector<uint16_t> dimg;       // 'dimg' references (iovl/iden/grid)
+  std::vector<uint8_t> data;        // idat payload (mski pixels / iovl or grid spec); empty for iden
+  uint32_t width = 8;               // 'ispe' size
+  uint32_t height = 8;
+  bool crop_to_1x1 = false;         // add a 'clap' that keeps a single pixel
 };

 // Assemble a minimal, self-contained HEIF file from an explicit item list. Every
-// item carries an 8x8 'ispe'; the single 'mski' base additionally carries a
+// item carries an 'ispe'; the single 'mski' base additionally carries a
 // 'mskC'. Items with data store it in 'idat' (construction_method 1).
 std::vector<uint8_t> build_file(const std::vector<Item>& items, uint16_t primary_id) {
-  const uint32_t W = 8, H = 8;
-
   std::vector<uint8_t> ftyp_payload;
   append_fourcc(ftyp_payload, "heic");
   put_u32_be(ftyp_payload, 0);
@@ -107,34 +126,46 @@ std::vector<uint8_t> build_file(const std::vector<Item>& items, uint16_t primary
   }
   auto iinf = make_box("iinf", iinf_payload, /*full=*/true);

-  // iprp / ipco: one ispe(8x8) per item, plus one shared mskC for the base.
+  // iprp / ipco: one mskC for the base, one clap, plus one ispe per image size
+  // (shared by all items of that size).
   // Property indices are 1-based in the order boxes appear in ipco.
-  std::vector<uint8_t> ispe_payload;
-  put_u32_be(ispe_payload, W);
-  put_u32_be(ispe_payload, H);
-  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> clap_payload;
+  for (uint32_t v : {1, 1, 1, 1, 0, 1, 0, 1}) {  // width 1/1, height 1/1, centered
+    put_u32_be(clap_payload, v);
+  }
+  auto clap = make_box("clap", clap_payload);
+
   std::vector<uint8_t> ipco_payload;
-  append(ipco_payload, ispe);   // property 1: ispe(8x8), shared by all items
-  append(ipco_payload, mskC);   // property 2: mskC, for the base
+  append(ipco_payload, mskC);   // property 1: mskC, for the base
+  append(ipco_payload, clap);   // property 2: clap
+
+  std::map<std::pair<uint32_t, uint32_t>, uint8_t> ispe_index;
+  for (const auto& it : items) {
+    if (ispe_index.emplace(std::make_pair(it.width, it.height),
+                           static_cast<uint8_t>(3 + ispe_index.size())).second) {
+      std::vector<uint8_t> ispe_payload;
+      put_u32_be(ispe_payload, it.width);
+      put_u32_be(ispe_payload, it.height);
+      append(ipco_payload, make_box("ispe", ispe_payload, /*full=*/true));
+    }
+  }
   auto ipco = make_box("ipco", ipco_payload);

   std::vector<uint8_t> ipma_payload;
   put_u32_be(ipma_payload, static_cast<uint32_t>(items.size()));  // entry_count
   for (const auto& it : items) {
     put_u16_be(ipma_payload, it.id);
+    ipma_payload.push_back(static_cast<uint8_t>(1 + (it.type == "mski") + it.crop_to_1x1));  // association_count
+    ipma_payload.push_back(0x80 | ispe_index[{it.width, it.height}]);  // essential, ispe
     if (it.type == "mski") {
-      ipma_payload.push_back(2);          // association_count
-      ipma_payload.push_back(0x80 | 1);   // essential, ispe
-      ipma_payload.push_back(0x80 | 2);   // essential, mskC
+      ipma_payload.push_back(0x80 | 1);   // essential, mskC
     }
-    else {
-      ipma_payload.push_back(1);
-      ipma_payload.push_back(0x80 | 1);   // essential, ispe
+    if (it.crop_to_1x1) {
+      ipma_payload.push_back(0x80 | 2);   // essential, clap
     }
   }
   auto ipma = make_box("ipma", ipma_payload, /*full=*/true);
@@ -227,7 +258,23 @@ std::vector<uint8_t> build_exponential_overlays(int depth) {
   return build_file(items, /*primary=*/2);
 }

-heif_error decode_primary(const std::vector<uint8_t>& data, bool& read_ok) {
+// Build a grid of 1x1 pixel tiles. Every tile is the same 'iden' item, whose 'clap'
+// crops one pixel out of an overlay with a canvas of source_size x source_size.
+// Without the fix, that canvas is built again for each of the tiles.
+std::vector<uint8_t> build_grid_of_cropped_tiles(uint8_t rows, uint8_t columns, uint16_t source_size) {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(64, 0x7F)});
+  items.push_back({2, "grid", std::vector<uint16_t>(rows * columns, 3),
+                   make_grid_spec(rows, columns, columns, rows), columns, rows});
+  items.push_back({3, "iden", {4}, {}, source_size, source_size, /*crop_to_1x1=*/true});
+  items.push_back({4, "iovl", {1}, make_overlay_spec(source_size, source_size, 1), source_size, source_size});
+
+  return build_file(items, /*primary=*/2);
+}
+
+// Decodes the whole primary image, or only its tile (tile_x, tile_y) with tile_x >= 0.
+heif_error decode_primary(const std::vector<uint8_t>& data, bool& read_ok, bool strict = false,
+                          int tile_x = -1, int tile_y = -1) {
   heif_context* ctx = heif_context_alloc();
   REQUIRE(ctx != nullptr);

@@ -246,9 +293,21 @@ heif_error decode_primary(const std::vector<uint8_t>& data, bool& read_ok) {
     return err;
   }

+  // Without strict decoding, a grid tile that cannot be decoded is only a warning
+  // (unless it exceeds a security limit).
+  heif_decoding_options* options = heif_decoding_options_alloc();
+  options->strict_decoding = strict;
+
   heif_image* img = nullptr;
-  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+  if (tile_x >= 0) {
+    err = heif_image_handle_decode_image_tile(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options,
+                                              static_cast<uint32_t>(tile_x), static_cast<uint32_t>(tile_y));
+  }
+  else {
+    err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, options);
+  }

+  heif_decoding_options_free(options);
   if (img) { heif_image_release(img); }
   heif_image_handle_release(handle);
   heif_context_free(ctx);
@@ -315,3 +374,101 @@ TEST_CASE("overlay amplification: a shallow legitimate overlay still decodes") {
   REQUIRE(read_ok);
   REQUIRE(err.code == heif_error_Ok);
 }
+
+// The number of decodes (3 per tile) is within the limit, but each tile decodes a
+// 2048x2048 image to use one pixel of it.
+TEST_CASE("overlay amplification: large image decoded again for each small grid tile is rejected") {
+  auto data = build_grid_of_cropped_tiles(/*rows=*/16, /*columns=*/16, /*source_size=*/2048);
+
+  // Also without strict decoding, where a broken grid tile is only a warning: an exceeded
+  // budget must not result in an image with blank tiles.
+  bool strict = GENERATE(true, false);
+  CAPTURE(strict);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, strict);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+}
+
+// Control: the same reference graph with a small source image is cheap and still decodes.
+TEST_CASE("overlay amplification: small image decoded again for each grid tile still decodes") {
+  auto data = build_grid_of_cropped_tiles(/*rows=*/16, /*columns=*/16, /*source_size=*/8);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/true);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}
+
+// Control: cropping a large image once is legitimate.
+TEST_CASE("overlay amplification: large image cropped to a single grid tile still decodes") {
+  auto data = build_grid_of_cropped_tiles(/*rows=*/1, /*columns=*/1, /*source_size=*/2048);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/true);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}
+
+// Control: a large grid that uses one tile item for all positions decodes that item many
+// times, but not more pixels than the grid has.
+TEST_CASE("overlay amplification: grid that repeats one tile still decodes") {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(512 * 512, 0x7F), 512, 512});
+  items.push_back({2, "grid", std::vector<uint16_t>(16 * 16, 1), make_grid_spec(16, 16, 8192, 8192), 8192, 8192});
+
+  auto data = build_file(items, /*primary=*/2);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/true);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}
+
+
+// Each call of heif_image_handle_decode_image_tile() is a top-level decode of its own.
+// The tile item is a 2048x2048 image cropped to its 1x1 tile, which a budget that is
+// computed from the items alone would allow for each of the 256 tiles. A tile only gets
+// its share of the budget of the whole image, so the tile item is refused.
+TEST_CASE("overlay amplification: single tile cropped from a large image is rejected") {
+  auto data = build_grid_of_cropped_tiles(/*rows=*/16, /*columns=*/16, /*source_size=*/2048);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/false, /*tile_x=*/3, /*tile_y=*/5);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code != heif_error_Ok);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+}
+
+// Control: the same tile with a small source image still decodes.
+TEST_CASE("overlay amplification: single tile cropped from a small image still decodes") {
+  auto data = build_grid_of_cropped_tiles(/*rows=*/16, /*columns=*/16, /*source_size=*/8);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/true, /*tile_x=*/3, /*tile_y=*/5);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}
+
+// Control: a single tile of a large grid of tiles of the cell size still decodes.
+TEST_CASE("overlay amplification: single tile of a large grid still decodes") {
+  std::vector<Item> items;
+  items.push_back({1, "mski", {}, std::vector<uint8_t>(512 * 512, 0x7F), 512, 512});
+  items.push_back({2, "grid", std::vector<uint16_t>(16 * 16, 1), make_grid_spec(16, 16, 8192, 8192), 8192, 8192});
+
+  auto data = build_file(items, /*primary=*/2);
+
+  bool read_ok = false;
+  heif_error err = decode_primary(data, read_ok, /*strict=*/true, /*tile_x=*/15, /*tile_y=*/15);
+
+  REQUIRE(read_ok);
+  REQUIRE(err.code == heif_error_Ok);
+}
diff --git a/tests/uncompressed_grid_tile_mismatch.cc b/tests/uncompressed_grid_tile_mismatch.cc
index ef6ea23a..cdc098a0 100644
--- a/tests/uncompressed_grid_tile_mismatch.cc
+++ b/tests/uncompressed_grid_tile_mismatch.cc
@@ -491,6 +491,19 @@ TEST_CASE("grid with a tile completely outside of the image")
 }


+TEST_CASE("a grid tile completely outside of the image is not decoded")
+{
+  // That the third tile is not decoded shows in that its format is not compared with the other tiles.
+  auto file = build_grid_file({{{{MONO, 8}}, 0xAA}, {{{MONO, 8}}, 0xBB}, {{{MONO, 16}}, 0xCC}}, 2 * TILE_W);
+
+  Outcome outcome = decode_repeatedly(file);
+  INFO("decode: " << outcome.message);
+  REQUIRE(outcome.code == heif_error_Ok);
+  CHECK(outcome.bit_depth == 8);
+  CHECK(outcome.first_row == row_of({0xAA, 0xBB}));
+}
+
+
 TEST_CASE("decoding single grid tiles refuses a tile that does not have the format of the other tiles")
 {
   SECTION("tiles of the same format") {