Commit 6ce2bba5 for libheif

commit 6ce2bba558a27b63a508e81c085025f91c89899b
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Sep 19 02:51:08 2026 +0200

    Reject in-band coded image sizes over the security limit for all codecs (GHSA-v8qw-hwjv-44hw)

    A crafted image can declare a small size in its container 'ispe' property while
    its bitstream declares a much larger coded frame. The container-level checks are
    based on 'ispe', so the oversized bitstream was handed to the decoder, which
    allocated a buffer for the in-band size before libheif rejected the mismatch.
    The advisory demonstrated this for AV1 with the libaom backend (a ~351-byte AVIF
    declaring 64x64 but coding 8192x8192..27648x27648, allocating hundreds of MB to
    >10 GB), but the same class affects every codec whose real frame size lives in
    the bitstream rather than in the container.

    Enforce the coded size in the codec-independent decode path, before any bytes
    reach a decoder plugin, so the fix is both codec- and backend-independent (it
    protects the ffmpeg backend too, which does no size check of its own):

      - Rename the per-decoder hook get_coded_image_size_from_config() to
        get_max_coded_image_size(const std::vector<uint8_t>&). The old name no longer
        described the behaviour: it now scans the whole bitstream, not just the
        configuration record. It is an internal method with a single caller.

      - decode_sequence_frame_from_compressed_data() now fetches the compressed data
        once and passes that same buffer to both the size gate and the decoder push,
        so the combined config+bitstream buffer is not built twice per decode.

      - AV1/AVIF: scan every OBU_SEQUENCE_HEADER in the combined configOBUs + item
        data for the largest max_frame_width/height (new
        find_max_av1_frame_size_in_stream()).

      - AVC/HEVC/VVC: scan every SPS NAL unit in the combined config + item data
        (new split_nal_units_4byte_length_prefixed()), not just the SPS in
        avcC/hvcC/vvcC, since an SPS carried in the item data also drives the
        decoder's allocation. Return the largest coded (pre-crop) size.

      - JPEG: parse the SOF marker dimensions (previously discarded) and gate on them.

      - JPEG 2000 / HTJ2K: parse the SIZ reference grid (Xsiz, Ysiz) from the
        codestream. This makes the check backend-independent; the OpenJPEG plugin's
        own grid gate (GHSA-q492-cfcm-895h) remains as a backstop.

    Uncompressed images are libheif's own decoder and are sized from the container,
    so they are not in this class.

    Add regression tests: tests/inband_coded_size_limit.cc (the advisory AV1 PoC
    plus HEVC/AVC/JPEG in-band attack files) and tests/nal_split.cc (NAL splitter
    edge cases).

diff --git a/libheif/codecs/avc_dec.cc b/libheif/codecs/avc_dec.cc
index 3a0a2ac7..1a51207f 100644
--- a/libheif/codecs/avc_dec.cc
+++ b/libheif/codecs/avc_dec.cc
@@ -22,7 +22,9 @@
 #include "avc_boxes.h"
 #include "error.h"
 #include "context.h"
+#include "plugins/nalu_utils.h"

+#include <algorithm>
 #include <string>


@@ -47,26 +49,51 @@ int Decoder_AVC::get_chroma_bits_per_pixel() const
 }


-Result<std::optional<ImageSize>> Decoder_AVC::get_coded_image_size_from_config() const
+Result<std::optional<ImageSize>> Decoder_AVC::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
 {
-  const auto& sps_set = m_avcC->getSequenceParameterSets();
+  // `compressed_data` is the combined configuration + bitstream buffer about to be
+  // pushed to the decoder. Scan it for every SPS NAL unit and return the largest coded picture
+  // size any of them declares. An SPS carried in the item data (not just in avcC)
+  // drives the decoder's buffer allocation and can be far larger than the
+  // container 'ispe', so the config record alone is not a sufficient gate.
+  bool found = false;
+  uint32_t max_width = 0;
+  uint32_t max_height = 0;
+
+  for (const auto& nal : split_nal_units_4byte_length_prefixed(compressed_data.data(), compressed_data.size())) {
+    const uint8_t* nal_data = nal.first;
+    size_t nal_size = nal.second;
+
+    // AVC NAL unit header (1 byte): forbidden_zero_bit(1), nal_ref_idc(2), nal_unit_type(5)
+    if (nal_size < 1) {
+      continue;
+    }
+    int nal_type = nal_data[0] & 0x1F;
+    if (nal_type != AVC_NAL_UNIT_SPS_NUT) {
+      continue;
+    }

-  for (const auto& sps : sps_set) {
-    if (sps.empty()) continue;
     Box_avcC::configuration scratch = m_avcC->get_configuration();
     uint32_t cropped_w = 0, cropped_h = 0;
     ImageSize coded{};
-
-    Error e = parse_sps_for_avcC_configuration(sps.data(), sps.size(), &scratch,
+    Error e = parse_sps_for_avcC_configuration(nal_data, nal_size, &scratch,
                                                &cropped_w, &cropped_h, &coded);
     if (e) {
-      return e;
+      // A malformed SPS we cannot parse is skipped rather than failing the whole
+      // decode; the decoder plugin applies its own limits when it reaches it.
+      continue;
     }

-    return std::optional<ImageSize>{coded};
+    found = true;
+    max_width = std::max(max_width, coded.width);
+    max_height = std::max(max_height, coded.height);
+  }
+
+  if (!found) {
+    return std::optional<ImageSize>{};
   }

-  return std::optional<ImageSize>{};
+  return std::optional<ImageSize>{ImageSize{max_width, max_height}};
 }


diff --git a/libheif/codecs/avc_dec.h b/libheif/codecs/avc_dec.h
index 5613e533..f5609b88 100644
--- a/libheif/codecs/avc_dec.h
+++ b/libheif/codecs/avc_dec.h
@@ -46,7 +46,7 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

-  Result<std::optional<ImageSize>> get_coded_image_size_from_config() const override;
+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;

 private:
   const std::shared_ptr<const Box_avcC> m_avcC;
diff --git a/libheif/codecs/avif_boxes.cc b/libheif/codecs/avif_boxes.cc
index e454bdc1..bf6525ba 100644
--- a/libheif/codecs/avif_boxes.cc
+++ b/libheif/codecs/avif_boxes.cc
@@ -27,6 +27,7 @@
 #include "file.h"
 #include <iomanip>
 #include <limits>
+#include <algorithm>
 #include <string>
 #include <cstring>

@@ -561,3 +562,173 @@ bool fill_av1C_configuration_from_stream(Box_av1C::configuration* out_config, co

   return true;
 }
+
+
+// Parse a single OBU_SEQUENCE_HEADER payload far enough to recover the coded
+// frame size (max_frame_width_minus_1 / max_frame_height_minus_1). The reader
+// must be positioned at the first bit of the sequence_header_obu() payload.
+//
+// Returns false when the header could not be parsed within the available data
+// (i.e. the reader ran past its end), in which case the recovered size is
+// unreliable and must be ignored. This mirrors the bit layout in
+// fill_av1C_configuration_from_stream() up to the frame size fields; it is kept
+// separate so the security scan cannot be perturbed by the colour-config parsing.
+static bool parse_av1_seq_header_max_frame_size(BitReader& reader,
+                                                uint32_t* out_max_width,
+                                                uint32_t* out_max_height)
+{
+  uint32_t dummy; // throw away value
+
+  bool decoder_model_info_present = false;
+  int buffer_delay_length_minus1 = 0;
+
+  reader.get_bits(3); // seq_profile
+  reader.get_bits(1); // still_picture
+
+  bool reduced_still_picture = reader.get_bits(1);
+  if (reduced_still_picture) {
+    reader.get_bits(5); // seq_level_idx[0]
+  }
+  else {
+    bool timing_info_present_flag = reader.get_bits(1);
+    if (timing_info_present_flag) {
+      // --- skip timing info
+      reader.skip_bytes(2 * 4);
+      bool equal_picture_interval = reader.get_bits(1);
+      if (equal_picture_interval) {
+        reader.get_uvlc(&dummy);
+      }
+
+      // --- skip decoder_model_info
+      decoder_model_info_present = reader.get_bits(1);
+      if (decoder_model_info_present) {
+        buffer_delay_length_minus1 = reader.get_bits(5);
+        reader.skip_bits(32);
+        reader.skip_bits(10);
+      }
+    }
+
+    bool initial_display_delay_present_flag = reader.get_bits(1);
+    int operating_points_cnt_minus1 = reader.get_bits(5);
+    for (int i = 0; i <= operating_points_cnt_minus1; i++) {
+      reader.skip_bits(12); // operating_point_idc
+      auto level = (int) reader.get_bits(5);
+      if (level > 7) {
+        reader.skip_bits(1); // tier
+      }
+
+      if (decoder_model_info_present) {
+        bool decoder_model_present_for_this = reader.get_bits(1);
+        if (decoder_model_present_for_this) {
+          int n = buffer_delay_length_minus1 + 1;
+          reader.skip_bits(n);
+          reader.skip_bits(n);
+          reader.skip_bits(1);
+        }
+      }
+
+      if (initial_display_delay_present_flag) {
+        bool initial_display_delay_present_for_this = reader.get_bits(1);
+        if (initial_display_delay_present_for_this) {
+          reader.get_bits(4);
+        }
+      }
+    }
+  }
+
+  int frame_width_bits_minus1 = reader.get_bits(4);
+  int frame_height_bits_minus1 = reader.get_bits(4);
+  uint32_t max_frame_width_minus1 = reader.get_bits(frame_width_bits_minus1 + 1);
+  uint32_t max_frame_height_minus1 = reader.get_bits(frame_height_bits_minus1 + 1);
+
+  // If parsing consumed more bits than the buffer held, get_bits() returned
+  // zero-padded values and the size is not trustworthy. Reject it so we neither
+  // wrongly accept nor wrongly reject based on garbage.
+  if (reader.get_bits_remaining() < 0) {
+    return false;
+  }
+
+  *out_max_width = max_frame_width_minus1 + 1;
+  *out_max_height = max_frame_height_minus1 + 1;
+  return true;
+}
+
+
+bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
+                                       uint32_t* out_max_width, uint32_t* out_max_height)
+{
+  // The combined AV1 bitstream that libheif pushes to a decoder plugin consists
+  // of the av1C configOBUs followed by the item/sample data. A conforming AVIF
+  // carries exactly one OBU_SEQUENCE_HEADER in the item data (and may duplicate
+  // it in configOBUs). We walk every OBU and keep the largest frame size found
+  // in any sequence header, because that is the buffer a decoder will allocate,
+  // independent of the (possibly much smaller) 'ispe' dimensions.
+
+  if (data == nullptr || dataSize == 0 ||
+      dataSize > (size_t) std::numeric_limits<int>::max()) {
+    return false;
+  }
+
+  BitReader reader(data, (int) dataSize);
+
+  bool found = false;
+  uint32_t max_width = 0;
+  uint32_t max_height = 0;
+
+  while (reader.get_bits_remaining() >= 8) {
+    obu_header_info header = read_obu_header_type(reader);
+
+    // read_obu_header_type() may read past the end on truncated input.
+    if (reader.get_bits_remaining() < 0) {
+      break;
+    }
+
+    // The reader is byte-aligned after the OBU header; this is where the payload
+    // begins. We parse a sequence header from a separate reader bounded to the
+    // payload so that neither the seq-header parse nor a following OBU can read
+    // across the declared OBU boundary.
+    size_t payload_start = reader.get_current_byte_index();
+    if (payload_start >= dataSize) {
+      break;
+    }
+
+    if (header.type == HEIF_OBU_SEQUENCE_HEADER) {
+      size_t avail = dataSize - payload_start;
+      size_t payload_len = avail;
+      if (header.has_size && header.size < payload_len) {
+        payload_len = (size_t) header.size;
+      }
+
+      BitReader seq_reader(data + payload_start, (int) payload_len);
+      uint32_t w = 0, h = 0;
+      if (parse_av1_seq_header_max_frame_size(seq_reader, &w, &h)) {
+        found = true;
+        // Track the largest width and height independently. A conforming file
+        // has a single sequence header (the AVIF spec even requires identical
+        // headers when several are present), so this equals that header's size.
+        // For a non-conforming file with differing headers it over-approximates
+        // rather than under-approximates the buffer any frame could demand,
+        // which is the safe direction for a security gate.
+        max_width = std::max(max_width, w);
+        max_height = std::max(max_height, h);
+      }
+    }
+
+    // Advance to the next OBU. Without an explicit size we cannot locate it.
+    if (!header.has_size) {
+      break;
+    }
+    if (header.size > (uint64_t) std::numeric_limits<int>::max()) {
+      break;
+    }
+
+    reader.skip_bytes((uint32_t) header.size);
+  }
+
+  if (found) {
+    *out_max_width = max_width;
+    *out_max_height = max_height;
+  }
+
+  return found;
+}
diff --git a/libheif/codecs/avif_boxes.h b/libheif/codecs/avif_boxes.h
index f7de6558..a25b7a86 100644
--- a/libheif/codecs/avif_boxes.h
+++ b/libheif/codecs/avif_boxes.h
@@ -168,4 +168,12 @@ Error fill_av1C_configuration(Box_av1C::configuration* inout_config, const std::

 bool fill_av1C_configuration_from_stream(Box_av1C::configuration* out_config, const uint8_t* data, int dataSize);

+// Scan a combined AV1 bitstream (av1C configOBUs + item/sample data) for every
+// OBU_SEQUENCE_HEADER and return the largest coded frame size (in luma samples)
+// declared by any of them. Returns false when no parseable sequence header was
+// found, in which case the size outputs are left untouched. Used to enforce the
+// security limits before the bitstream reaches any AV1 decoder plugin.
+bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
+                                       uint32_t* out_max_width, uint32_t* out_max_height);
+
 #endif
diff --git a/libheif/codecs/avif_dec.cc b/libheif/codecs/avif_dec.cc
index 3ee6169c..64ccc8b7 100644
--- a/libheif/codecs/avif_dec.cc
+++ b/libheif/codecs/avif_dec.cc
@@ -38,6 +38,31 @@ Result<std::vector<uint8_t>> Decoder_AVIF::read_bitstream_configuration_data() c
 }


+Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
+{
+  // The AV1 sequence header carries the coded frame size
+  // (max_frame_width_minus_1 / max_frame_height_minus_1). Per the AVIF
+  // specification it is present in the image item data, and it may additionally
+  // be duplicated in the av1C configOBUs. A decoder allocates buffers for that
+  // coded size, which can be far larger than the (possibly deliberately small)
+  // 'ispe' dimensions on which the container-level size checks are based.
+  //
+  // `compressed_data` is the combined configOBUs + item data buffer that is about
+  // to be pushed to the decoder plugin. Scan it for the largest sequence-header
+  // frame size so the shared decode path can reject over-limit inputs before ANY
+  // AV1 plugin (aom, dav1d, ffmpeg, ...) allocates a frame buffer.
+  uint32_t max_width = 0;
+  uint32_t max_height = 0;
+  if (!find_max_av1_frame_size_in_stream(compressed_data.data(), compressed_data.size(),
+                                         &max_width, &max_height)) {
+    // No parseable sequence header (e.g. a non-sync frame in a sequence).
+    return std::optional<ImageSize>{};
+  }
+
+  return std::optional<ImageSize>{ImageSize{max_width, max_height}};
+}
+
+
 int Decoder_AVIF::get_luma_bits_per_pixel() const
 {
   Box_av1C::configuration config = m_av1C->get_configuration();
diff --git a/libheif/codecs/avif_dec.h b/libheif/codecs/avif_dec.h
index 441f1864..3478906d 100644
--- a/libheif/codecs/avif_dec.h
+++ b/libheif/codecs/avif_dec.h
@@ -46,6 +46,8 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+
 private:
   const std::shared_ptr<const Box_av1C> m_av1C;
 };
diff --git a/libheif/codecs/decoder.cc b/libheif/codecs/decoder.cc
index d1645abd..e12fb18d 100644
--- a/libheif/codecs/decoder.cc
+++ b/libheif/codecs/decoder.cc
@@ -314,6 +314,35 @@ Decoder::~Decoder()
 }


+std::vector<std::pair<const uint8_t*, size_t>>
+split_nal_units_4byte_length_prefixed(const uint8_t* data, size_t size)
+{
+  std::vector<std::pair<const uint8_t*, size_t>> units;
+
+  size_t ptr = 0;
+  while (ptr + 4 <= size) {
+    uint32_t nal_size = (uint32_t(data[ptr]) << 24) | (uint32_t(data[ptr + 1]) << 16) |
+                        (uint32_t(data[ptr + 2]) << 8) | uint32_t(data[ptr + 3]);
+    ptr += 4;
+
+    // A length that runs past the end of the buffer means the stream is
+    // malformed; stop rather than read out of bounds.
+    if (nal_size > size - ptr) {
+      break;
+    }
+
+    if (nal_size > 0) {
+      units.emplace_back(data + ptr, (size_t) nal_size);
+    }
+
+    ptr += nal_size;
+  }
+
+  return units;
+}
+
+
+
 void Decoder::release_decoder()
 {
   if (m_decoder) {
@@ -428,14 +457,34 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
     return pluginErr;
   }

-  // Reject memory-bomb inputs whose codec configuration record (SPS) declares
-  // a coded picture size beyond libheif's security limits, before handing any
-  // bytes to the decoder plugin. Codecs whose configuration record does not
-  // carry dimensions (e.g. AV1's av1C) return nullopt and skip the check.
-  //
-  // TODO: check this also in the decoder plugin since SPS packets may be
-  //       found within the actual image bitstream.
-  auto codedSize = get_coded_image_size_from_config();
+  // Fetch the compressed data once. The same buffer is used both to enforce the
+  // security limits below and to feed the decoder, so we neither re-read the
+  // iloc extents nor rebuild the combined config+bitstream buffer twice.
+  auto dataResult = get_compressed_data(upload_configuration_NALs);
+  if (!dataResult) {
+    return dataResult.error();
+  }
+
+  // Check that we are pushing at least some data into the decoder.
+  // Some decoders (e.g. aom) do not complain when the input data is empty and we might
+  // get stuck in an endless decoding loop, waiting for the decompressed image.
+  if (dataResult->size() == 0) {
+    return Error{
+      heif_error_Invalid_input,
+      heif_suberror_Unspecified,
+      "Input with empty data extent."
+    };
+  }
+
+  // Reject memory-bomb inputs whose coded picture size exceeds libheif's
+  // security limits, before handing any bytes to the decoder plugin. The coded
+  // (pre-crop) size the decoder will allocate lives in the bitstream: the SPS for
+  // AVC/HEVC/VVC, the Sequence Header OBU for AV1/AVIF, or the SOF marker for
+  // JPEG. It can be far larger than the 'ispe' dimensions, and for the NAL codecs
+  // an SPS may sit in the item data rather than only in the config record, so we
+  // scan the whole buffer that is about to be pushed. Codecs that expose no coded
+  // size return nullopt and skip the check.
+  auto codedSize = get_max_coded_image_size(*dataResult);
   if (codedSize.is_error()) {
     return codedSize.error();
   }
@@ -486,23 +535,6 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
     }
   }

-  auto dataResult = get_compressed_data(upload_configuration_NALs);
-  if (!dataResult) {
-    return dataResult.error();
-  }
-
-  // Check that we are pushing at least some data into the decoder.
-  // Some decoders (e.g. aom) do not complain when the input data is empty and we might
-  // get stuck in an endless decoding loop, waiting for the decompressed image.
-
-  if (dataResult->size() == 0) {
-    return Error{
-      heif_error_Invalid_input,
-      heif_suberror_Unspecified,
-      "Input with empty data extent."
-    };
-  }
-
   //std::cout << "Decoder::decode_sequence_frame_from_compressed_data push " << dataResult->size() << "\n";
   if (m_decoder_plugin->plugin_api_version >= 5 && m_decoder_plugin->push_data2) {
     err = m_decoder_plugin->push_data2(m_decoder, dataResult->data(), dataResult->size(), user_data);
diff --git a/libheif/codecs/decoder.h b/libheif/codecs/decoder.h
index 7435badf..1a353d59 100644
--- a/libheif/codecs/decoder.h
+++ b/libheif/codecs/decoder.h
@@ -45,6 +45,19 @@ struct ImageSize
 };


+// Split a buffer of NAL units, each prefixed by a 4-byte big-endian length, into
+// non-owning (pointer, length) spans that point into `data`. Parsing stops at the
+// first malformed length (a prefix or payload that runs past the end of the
+// buffer); NAL units already collected are still returned.
+//
+// This mirrors exactly how the AVC/HEVC/VVC decoder plugins walk the combined
+// configuration+bitstream buffer they are handed (see e.g. decoder_libde265.cc),
+// so the returned spans are the same NAL units the codec will actually see. It is
+// used to scan for in-band SPS NAL units when enforcing the security limits.
+std::vector<std::pair<const uint8_t*, size_t>>
+split_nal_units_4byte_length_prefixed(const uint8_t* data, size_t size);
+
+
 // Specifies the input data for decoding.
 // For images, this points to the iloc extents.
 // For sequences, this points to the track data.
@@ -106,19 +119,25 @@ public:
   // Returns a stream of packets. Each packet is starts with a 4-byte size (MSB first).
   [[nodiscard]] virtual Result<std::vector<uint8_t>> read_bitstream_configuration_data() const = 0;

-  // Returns the *coded* picture size from the codec configuration record (the
-  // SPS for HEVC/AVC/VVC) — i.e. the buffer dimensions the decoder will
-  // actually allocate, BEFORE conformance-window cropping. The cropped output
-  // size is unsuitable for security checks: a malicious file can declare a
-  // huge SPS picture size with a near-equal-sized conformance window, so the
-  // displayed image looks small while the decoder still allocates the full
-  // uncropped buffer.
+  // Returns the largest *coded* picture size declared anywhere in `compressed_data`
+  // (the exact buffer that will be pushed to the decoder), i.e. the buffer
+  // dimensions the decoder will actually allocate, BEFORE conformance-window
+  // cropping. The cropped output size is unsuitable for security checks: a
+  // malicious file can declare a huge coded size with a near-equal-sized
+  // conformance window, so the displayed image looks small while the decoder
+  // still allocates the full uncropped buffer.
+  //
+  // The coded size lives in the bitstream, not (only) in the codec configuration
+  // record: the SPS for AVC/HEVC/VVC (which may appear in the item data, not just
+  // in avcC/hvcC/vvcC), the Sequence Header OBU for AV1/AVIF, or the SOF marker
+  // for JPEG. Overrides therefore scan the whole passed buffer and return the
+  // maximum, so an over-limit size cannot be smuggled past the container 'ispe'.
   //
-  // Returns nullopt when the codec does not store dimensions in its
-  // configuration record (e.g. AV1's av1C) or when no SPS NAL is present.
-  // Returns Error only on a structurally invalid configuration record.
+  // Returns nullopt when the codec exposes no coded size in this buffer (e.g. no
+  // SPS/sequence-header/SOF present, as in a non-sync sequence frame).
+  // Returns Error only on a structurally invalid header.
   [[nodiscard]] virtual Result<std::optional<ImageSize>>
-  get_coded_image_size_from_config() const
+  get_max_coded_image_size(const std::vector<uint8_t>& /*compressed_data*/) const
   {
     return std::optional<ImageSize>{};
   }
diff --git a/libheif/codecs/hevc_dec.cc b/libheif/codecs/hevc_dec.cc
index 5d2bb324..d7458b42 100644
--- a/libheif/codecs/hevc_dec.cc
+++ b/libheif/codecs/hevc_dec.cc
@@ -24,6 +24,7 @@
 #include "context.h"
 #include "plugins/nalu_utils.h"

+#include <algorithm>
 #include <string>


@@ -51,29 +52,51 @@ int Decoder_HEVC::get_chroma_bits_per_pixel() const
 }


-Result<std::optional<ImageSize>> Decoder_HEVC::get_coded_image_size_from_config() const
+Result<std::optional<ImageSize>> Decoder_HEVC::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
 {
-  const auto& nal_arrays = m_hvcC->get_configuration().m_nal_array;
-
-  for (const auto& arr : nal_arrays) {
-    if (arr.m_NAL_unit_type != HEVC_NAL_UNIT_SPS_NUT || arr.m_nal_units.empty()) {
+  // `compressed_data` is the combined configuration + bitstream buffer about to be
+  // pushed to the decoder. Scan it for every SPS NAL unit and return the largest coded picture
+  // size any of them declares. An SPS carried in the item data (not just in hvcC)
+  // drives the decoder's buffer allocation and can be far larger than the
+  // container 'ispe', so the config record alone is not a sufficient gate.
+  bool found = false;
+  uint32_t max_width = 0;
+  uint32_t max_height = 0;
+
+  for (const auto& nal : split_nal_units_4byte_length_prefixed(compressed_data.data(), compressed_data.size())) {
+    const uint8_t* nal_data = nal.first;
+    size_t nal_size = nal.second;
+
+    // HEVC NAL unit header (2 bytes): forbidden_zero_bit(1), nal_unit_type(6), ...
+    if (nal_size < 2) {
+      continue;
+    }
+    int nal_type = (nal_data[0] >> 1) & 0x3F;
+    if (nal_type != HEVC_NAL_UNIT_SPS_NUT) {
       continue;
     }

-    const std::vector<uint8_t>& sps = arr.m_nal_units[0];
     HEVCDecoderConfigurationRecord scratch = m_hvcC->get_configuration();
     uint32_t cropped_w = 0, cropped_h = 0;
     ImageSize coded{};
-    Error e = parse_sps_for_hvcC_configuration(sps.data(), sps.size(), &scratch,
+    Error e = parse_sps_for_hvcC_configuration(nal_data, nal_size, &scratch,
                                                &cropped_w, &cropped_h, &coded);
     if (e) {
-      return e;
+      // A malformed SPS we cannot parse is skipped rather than failing the whole
+      // decode; the decoder plugin applies its own limits when it reaches it.
+      continue;
     }

-    return std::optional<ImageSize>{coded};
+    found = true;
+    max_width = std::max(max_width, coded.width);
+    max_height = std::max(max_height, coded.height);
+  }
+
+  if (!found) {
+    return std::optional<ImageSize>{};
   }

-  return std::optional<ImageSize>{};
+  return std::optional<ImageSize>{ImageSize{max_width, max_height}};
 }


diff --git a/libheif/codecs/hevc_dec.h b/libheif/codecs/hevc_dec.h
index 7c0a9a86..27bc324a 100644
--- a/libheif/codecs/hevc_dec.h
+++ b/libheif/codecs/hevc_dec.h
@@ -47,7 +47,7 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

-  Result<std::optional<ImageSize>> get_coded_image_size_from_config() const override;
+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;

 private:
   const std::shared_ptr<const Box_hvcC> m_hvcC;
diff --git a/libheif/codecs/jpeg2000_dec.cc b/libheif/codecs/jpeg2000_dec.cc
index 1569c5b7..586b69cc 100644
--- a/libheif/codecs/jpeg2000_dec.cc
+++ b/libheif/codecs/jpeg2000_dec.cc
@@ -32,6 +32,34 @@ Result<std::vector<uint8_t>> Decoder_JPEG2000::read_bitstream_configuration_data
 }


+Result<std::optional<ImageSize>> Decoder_JPEG2000::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
+{
+  // The JPEG 2000 coded size is the reference grid (Xsiz, Ysiz) declared in the
+  // SIZ marker of the codestream, which lives in the item data. A decoder performs
+  // its tile and coefficient arithmetic over the full reference grid, not just the
+  // visible window (Xsiz-XOsiz, Ysiz-YOsiz), so the reference grid is the size the
+  // decoder effectively allocates over and can be far larger than the container
+  // 'ispe'. Parse it here so the shared decode path can reject over-limit inputs
+  // before ANY J2K plugin (openjpeg, ffmpeg, ...) runs -- the openjpeg plugin has
+  // its own equivalent gate (GHSA-q492-cfcm-895h), this makes the check
+  // backend-independent.
+  JPEG2000MainHeader header;
+  Error err = header.parseHeader(compressed_data);
+  if (err) {
+    // Not a parseable codestream header; let the decoder plugin deal with it.
+    return std::optional<ImageSize>{};
+  }
+
+  uint32_t w = header.getXSize();
+  uint32_t h = header.getYSize();
+  if (w == 0 || h == 0) {
+    return std::optional<ImageSize>{};
+  }
+
+  return std::optional<ImageSize>{ImageSize{w, h}};
+}
+
+
 int Decoder_JPEG2000::get_luma_bits_per_pixel() const
 {
   Result<std::vector<uint8_t>> imageDataResult = get_compressed_data(true);
diff --git a/libheif/codecs/jpeg2000_dec.h b/libheif/codecs/jpeg2000_dec.h
index 9cc842d4..7fb41ed8 100644
--- a/libheif/codecs/jpeg2000_dec.h
+++ b/libheif/codecs/jpeg2000_dec.h
@@ -49,6 +49,8 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+
 private:
   const std::shared_ptr<const Box_j2kH> m_j2kH;
 };
diff --git a/libheif/codecs/jpeg_dec.cc b/libheif/codecs/jpeg_dec.cc
index a7aa43fd..f8f25d6a 100644
--- a/libheif/codecs/jpeg_dec.cc
+++ b/libheif/codecs/jpeg_dec.cc
@@ -56,7 +56,15 @@ Error Decoder_JPEG::parse_SOF()
     return dataResult.error();
   }

-  const std::vector<uint8_t>& data = *dataResult;
+  return parse_SOF(*dataResult);
+}
+
+
+Error Decoder_JPEG::parse_SOF(const std::vector<uint8_t>& data)
+{
+  if (m_config) {
+    return Error::Ok;
+  }

   Error error_invalidSOF{heif_error_Invalid_input,
                                heif_suberror_Unspecified,
@@ -71,6 +79,9 @@ Error Decoder_JPEG::parse_SOF()

       ConfigInfo info;
       info.sample_precision = data[i + 4];
+      // SOF layout: FF Cx | Lf(2) | precision(1) | Y=height(2) | X=width(2) | Nf(1)
+      info.coded_height = (uint32_t(data[i + 5]) << 8) | data[i + 6];
+      info.coded_width  = (uint32_t(data[i + 7]) << 8) | data[i + 8];
       info.nComponents = data[i + 9];

       if (i + 11 + 3 * info.nComponents >= data.size()) {
@@ -140,6 +151,26 @@ int Decoder_JPEG::get_chroma_bits_per_pixel() const
 }


+Result<std::optional<ImageSize>> Decoder_JPEG::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
+{
+  // The JPEG coded size lives in the SOF marker inside the bitstream (there is no
+  // separate configuration record that carries it). `compressed_data` is the
+  // buffer about to be handed to the decoder, so an oversized SOF is caught here
+  // before libjpeg allocates, regardless of the (possibly tiny) 'ispe'.
+  Error err = const_cast<Decoder_JPEG*>(this)->parse_SOF(compressed_data);
+  if (err) {
+    // No parseable SOF marker: skip the gate and let the decoder handle it.
+    return std::optional<ImageSize>{};
+  }
+
+  if (m_config->coded_width == 0 || m_config->coded_height == 0) {
+    return std::optional<ImageSize>{};
+  }
+
+  return std::optional<ImageSize>{ImageSize{m_config->coded_width, m_config->coded_height}};
+}
+
+
 Error Decoder_JPEG::get_coded_image_colorspace(heif_colorspace* out_colorspace, heif_chroma* out_chroma) const
 {
   Error err = const_cast<Decoder_JPEG*>(this)->parse_SOF();
diff --git a/libheif/codecs/jpeg_dec.h b/libheif/codecs/jpeg_dec.h
index 4de18eb5..f8752ebe 100644
--- a/libheif/codecs/jpeg_dec.h
+++ b/libheif/codecs/jpeg_dec.h
@@ -48,6 +48,8 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;
+
 private:
   const std::shared_ptr<const Box_jpgC> m_jpgC; // Optional jpgC box. May be NULL.

@@ -58,11 +60,16 @@ private:
     uint8_t nComponents = 0;
     uint8_t h_sampling[3]{};
     uint8_t v_sampling[3]{};
+
+    // Coded frame size from the SOF marker (the buffer the decoder allocates).
+    uint32_t coded_width = 0;
+    uint32_t coded_height = 0;
   };

   std::optional<ConfigInfo> m_config;

   Error parse_SOF();
+  Error parse_SOF(const std::vector<uint8_t>& data);
 };

 #endif
diff --git a/libheif/codecs/vvc_dec.cc b/libheif/codecs/vvc_dec.cc
index d363cf6a..4741b6df 100644
--- a/libheif/codecs/vvc_dec.cc
+++ b/libheif/codecs/vvc_dec.cc
@@ -24,6 +24,7 @@
 #include "context.h"
 #include "plugins/nalu_utils.h"

+#include <algorithm>
 #include <string>


@@ -57,23 +58,52 @@ int Decoder_VVC::get_chroma_bits_per_pixel() const
 }


-Result<std::optional<ImageSize>> Decoder_VVC::get_coded_image_size_from_config() const
+Result<std::optional<ImageSize>> Decoder_VVC::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const
 {
-  const std::vector<uint8_t>* sps = m_vvcC->get_first_nal_of_type(VVC_NAL_UNIT_SPS_NUT);
-  if (!sps || sps->empty()) {
-    return std::optional<ImageSize>{};
+  // `compressed_data` is the combined configuration + bitstream buffer about to be
+  // pushed to the decoder. Scan it for every SPS NAL unit and return the largest coded picture
+  // size any of them declares. An SPS carried in the item data (not just in vvcC)
+  // drives the decoder's buffer allocation and can be far larger than the
+  // container 'ispe', so the config record alone is not a sufficient gate.
+  bool found = false;
+  uint32_t max_width = 0;
+  uint32_t max_height = 0;
+
+  for (const auto& nal : split_nal_units_4byte_length_prefixed(compressed_data.data(), compressed_data.size())) {
+    const uint8_t* nal_data = nal.first;
+    size_t nal_size = nal.second;
+
+    // VVC NAL unit header (2 bytes): forbidden_zero_bit(1), nuh_reserved_zero_bit(1),
+    // nuh_layer_id(6), nal_unit_type(5), nuh_temporal_id_plus1(3)
+    if (nal_size < 2) {
+      continue;
+    }
+    int nal_type = (nal_data[1] >> 3) & 0x1F;
+    if (nal_type != VVC_NAL_UNIT_SPS_NUT) {
+      continue;
+    }
+
+    Box_vvcC::configuration scratch = m_vvcC->get_configuration();
+    uint32_t cropped_w = 0, cropped_h = 0;
+    ImageSize coded{};
+    Error e = parse_sps_for_vvcC_configuration(nal_data, nal_size, &scratch,
+                                               &cropped_w, &cropped_h, &coded);
+    if (e) {
+      // A malformed SPS we cannot parse is skipped rather than failing the whole
+      // decode; the decoder plugin applies its own limits when it reaches it.
+      continue;
+    }
+
+    found = true;
+    max_width = std::max(max_width, coded.width);
+    max_height = std::max(max_height, coded.height);
   }

-  Box_vvcC::configuration scratch = m_vvcC->get_configuration();
-  uint32_t cropped_w = 0, cropped_h = 0;
-  ImageSize coded{};
-  Error e = parse_sps_for_vvcC_configuration(sps->data(), sps->size(), &scratch,
-                                             &cropped_w, &cropped_h, &coded);
-  if (e) {
-    return e;
+  if (!found) {
+    return std::optional<ImageSize>{};
   }

-  return std::optional<ImageSize>{coded};
+  return std::optional<ImageSize>{ImageSize{max_width, max_height}};
 }


diff --git a/libheif/codecs/vvc_dec.h b/libheif/codecs/vvc_dec.h
index 2ad19344..7c45dab8 100644
--- a/libheif/codecs/vvc_dec.h
+++ b/libheif/codecs/vvc_dec.h
@@ -47,7 +47,7 @@ public:

   Result<std::vector<uint8_t>> read_bitstream_configuration_data() const override;

-  Result<std::optional<ImageSize>> get_coded_image_size_from_config() const override;
+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data) const override;

 private:
   const std::shared_ptr<const Box_vvcC> m_vvcC;
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 433a3355..b4ce6bb3 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -43,6 +43,7 @@ if (WITH_REDUCED_VISIBILITY)
     message(WARNING "Several unit tests have been disabled because they can only be compiled with full symbol visibility (WITH_REDUCED_VISIBILITY=OFF)")
 else()
     add_libheif_test(bitstream_tests)
+    add_libheif_test(nal_split)
     add_libheif_test(box_equals)
     add_libheif_test(clap_zero_size)
     add_libheif_test(fraction)
@@ -86,6 +87,7 @@ add_libheif_test(entity_groups)
 add_libheif_test(extended_type)
 add_libheif_test(grid_tile_missing)
 add_libheif_test(iden_declared_size)
+add_libheif_test(inband_coded_size_limit)
 add_libheif_test(jpeg2000_openjpeg_grid_limit)
 add_libheif_test(item_properties)
 add_libheif_test(item_writing)
diff --git a/tests/inband_coded_size_limit.cc b/tests/inband_coded_size_limit.cc
new file mode 100644
index 00000000..d3e73234
--- /dev/null
+++ b/tests/inband_coded_size_limit.cc
@@ -0,0 +1,168 @@
+/*
+  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 tests for the in-band coded-size security gate.
+//
+// The container 'ispe' can declare a small image while the actual coded size
+// lives in the bitstream: an AV1 sequence header OBU, an SPS NAL for AVC/HEVC/VVC,
+// or the SOF marker for JPEG. That coded size is what the decoder allocates. For the NAL codecs the
+// SPS may sit in the item data rather than only in the avcC/hvcC/vvcC record,
+// and for JPEG the SOF is always in the bitstream. libheif now scans the whole
+// combined config+bitstream buffer for the largest coded size and rejects it
+// against the (ispe-tightened) security limits before any decoder plugin runs.
+//
+// Each file below carries a 64x64 (HEVC: 320x240) 'ispe' but an oversized coded
+// size in the bitstream, and must be rejected with a security-limit error.
+
+#include "catch_amalgamated.hpp"
+#include "libheif/heif.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace {
+
+std::vector<uint8_t> base64_decode(const std::string& in) {
+  auto val = [](char c) -> int {
+    if (c >= 'A' && c <= 'Z') return c - 'A';
+    if (c >= 'a' && c <= 'z') return c - 'a' + 26;
+    if (c >= '0' && c <= '9') return c - '0' + 52;
+    if (c == '+') return 62;
+    if (c == '/') return 63;
+    return -1;
+  };
+  std::vector<uint8_t> out;
+  int bits = 0;
+  uint32_t acc = 0;
+  for (char c : in) {
+    if (c == '=' || c == '\n' || c == '\r') continue;
+    int v = val(c);
+    if (v < 0) continue;
+    acc = (acc << 6) | uint32_t(v);
+    bits += 6;
+    if (bits >= 8) { bits -= 8; out.push_back(uint8_t((acc >> bits) & 0xFF)); }
+  }
+  return out;
+}
+
+// Decode `b64` and assert it is rejected with a security-limit error before the
+// decoder allocates. Returns without asserting if no decoder for `format` is built.
+void expect_security_reject(heif_compression_format format, const char* b64) {
+  if (!heif_have_decoder_for_format(format)) {
+    SKIP("no decoder for this format built, skipping");
+  }
+
+  std::vector<uint8_t> data = base64_decode(b64);
+
+  heif_context* ctx = heif_context_alloc();
+  REQUIRE(ctx != nullptr);
+
+  heif_error err = heif_context_read_from_memory_without_copy(ctx, data.data(), data.size(), nullptr);
+  REQUIRE(err.code == heif_error_Ok);
+
+  heif_image_handle* handle = nullptr;
+  err = heif_context_get_primary_image_handle(ctx, &handle);
+  REQUIRE(err.code == heif_error_Ok);
+  REQUIRE(handle != nullptr);
+
+  heif_image* img = nullptr;
+  err = heif_decode_image(handle, &img, heif_colorspace_undefined, heif_chroma_undefined, nullptr);
+
+  REQUIRE(err.code == heif_error_Memory_allocation_error);
+  REQUIRE(err.subcode == heif_suberror_Security_limit_exceeded);
+
+  if (img) heif_image_release(img);
+  heif_image_handle_release(handle);
+  heif_context_free(ctx);
+}
+
+// HEVC item, 320x240 ispe, with an extra SPS (declaring 2000x2000) injected into
+// the item data. The hvcC SPS (320x240) passes the tightened limit, so only the
+// in-band 2000x2000 SPS can trip the gate.
+const char* kHevcInbandB64 =
+  "AAAAHGZ0eXBoZWljAAAAAG1pZjFoZWljbWlhZgAAAVZtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAA"
+  "ACJpbG9jAAAAAERAAAEAAQAAAAABegABAAAAAAAAAKQAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABodmMxAAAAAA5waXRt"
+  "AAAAAAABAAAA1mlwcnAAAAC3aXBjbwAAAHhodmNDAQNwAAAAAAAAAAAAPPAA/P34+AAADwNgAAEAGEABDAH//wNwAAADAJAAAAMA"
+  "AAMAPLoCQGEAAQArQgEBA3AAAAMAkAAAAwAAAwA8oAoIDxZbqSSmubgIaDAgAAADAyAAAAMAIWIAAQAHRAHBcrBiQAAAABNjb2xy"
+  "bmNseAABAA0ABoAAAAAUaXNwZQAAAAAAAAFAAAAA8AAAABBwaXhpAAAAAAMICAgAAAAXaXBtYQAAAAAAAAABAAEEgQIDBAAAAKxt"
+  "ZGF0AAAALUIBAQNwAAADAJAAAAMAAAMAlqAD6IAfRZbqSSmubgIaDAgAAAMAyAAAAwAIQAAAAG8oAa8E8hpVEoBBMmnb/xh///+y"
+  "jH//RNiT0cYM7GDOTinQihDqlAAAAwAAAwB5QABlUvVKAAADAAADAAADAAADAAAacAAAAwAAAwAAAwAAAwAAAwAAX0AAAAMAAAMA"
+  "AAMAAAMAAAMAAAMAAAMAAx4=";
+
+// AVC item, 64x64 ispe, avcC carrying no SPS (only a PPS); the SPS (declaring
+// 1024x512) is in the item data. The old config-record-only check saw no SPS.
+const char* kAvcInbandB64 =
+  "AAAAGGZ0eXBtaWYxAAAAAG1pZjFoZWljAAAA0W1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAADnBp"
+  "dG0AAAAAAAEAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABhdmMxAAAAAFFpcHJwAAAANGlwY28AAAAYYXZjQwFkACj/4AEA"
+  "B2joQ4OSyLAAAAAUaXNwZQAAAAAAAABAAAAAQAAAABVpcG1hAAAAAAAAAAEAAQKBAgAAACJpbG9jAAAAAERAAAEAAQAAAAAA8QAB"
+  "AAAAAAAAAB4AAAAmbWRhdAAAABpnZAAorHIEQEAEGhAAAAMAEAAAAwMg8YMYRg==";
+
+// JPEG item, 64x64 ispe, with a SOF marker declaring 8000x8000.
+const char* kJpegSofB64 =
+  "AAAAGGZ0eXBtaWYxAAAAAG1pZjFoZWljAAAAuG1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAADnBp"
+  "dG0AAAAAAAEAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABqcGVnAAAAADhpcHJwAAAAHGlwY28AAAAUaXNwZQAAAAAAAABA"
+  "AAAAQAAAABRpcG1hAAAAAAAAAAEAAQGBAAAAImlsb2MAAAAAREAAAQABAAAAAADYAAEAAAAAAAAChAAAAoxtZGF0/9j/4AAQSkZJ"
+  "RgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0o"
+  "MCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/"
+  "wAARCB9AH0ADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQID"
+  "AAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZn"
+  "aGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx"
+  "8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJB"
+  "UQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3"
+  "eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6"
+  "/9oADAMBAAIRAxEAPwCKiiivjz74KKKKACiiigAooooA/9k=";
+
+
+// AVIF item, 64x64 ispe, with an in-band AV1 Sequence Header OBU coding 8192x8192.
+// This is the original advisory reproducer (GHSA-v8qw-hwjv-44hw,
+// poc_av1_huge-frame-8192.avif).
+const char* kAvifPocB64 =
+  "AAAAHGZ0eXBhdmlmAAAAAG1pZjFhdmlmbWlhZgAAAM9tZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAA"
+  "AAAAAA5waXRtAAAAAAABAAAAI2lpbmYAAAAAAAEAAAAVaW5mZQIAAAAAAQAAYXYwMQAAAABRaXBycAAAADRpcGNvAAAAFGlz"
+  "cGUAAAAAAAAAQAAAAEAAAAAYYXYxQ4ERDAAKCgAAAAKv/4lfIAgAAAAVaXBtYQAAAAAAAAABAAECgYIAAAAgaWxvYwEAAABE"
+  "AAABAAEAAAAAAAEAAADzAAAAbAAAAHRtZGF0EgAKDQAAAIP8f/x//Er5AEAyWRAAkLQAggQQAACAACUAKPPXDGWYHeUNJr+m"
+  "mLGOwbZyMlmA0easG5s7ljg1k8Hjl6lwngAo89cMZZgd5Q0mv6aYsY7BtnIyWYDR5qwbmzuWODWTweOXqXCe"
+;
+
+} // namespace
+
+
+TEST_CASE("avif: reject in-band AV1 sequence header exceeding the security limit") {
+  expect_security_reject(heif_compression_AV1, kAvifPocB64);
+}
+
+TEST_CASE("hevc: reject in-band SPS exceeding the security limit") {
+  expect_security_reject(heif_compression_HEVC, kHevcInbandB64);
+}
+
+TEST_CASE("avc: reject in-band SPS exceeding the security limit") {
+  expect_security_reject(heif_compression_AVC, kAvcInbandB64);
+}
+
+TEST_CASE("jpeg: reject oversized SOF exceeding the security limit") {
+  expect_security_reject(heif_compression_JPEG, kJpegSofB64);
+}
diff --git a/tests/nal_split.cc b/tests/nal_split.cc
new file mode 100644
index 00000000..0a481648
--- /dev/null
+++ b/tests/nal_split.cc
@@ -0,0 +1,75 @@
+/*
+  libheif unit tests - NAL unit splitter
+
+  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.
+*/
+
+#include "catch_amalgamated.hpp"
+#include "codecs/decoder.h"
+
+#include <cstdint>
+#include <vector>
+
+TEST_CASE("split_nal_units: two well-formed NAL units") {
+  std::vector<uint8_t> buf{
+      0x00, 0x00, 0x00, 0x03, 0xAA, 0xBB, 0xCC,   // NAL of 3 bytes
+      0x00, 0x00, 0x00, 0x02, 0xDD, 0xEE};        // NAL of 2 bytes
+  auto units = split_nal_units_4byte_length_prefixed(buf.data(), buf.size());
+  REQUIRE(units.size() == 2);
+  REQUIRE(units[0].second == 3);
+  REQUIRE(units[0].first[0] == 0xAA);
+  REQUIRE(units[1].second == 2);
+  REQUIRE(units[1].first[0] == 0xDD);
+}
+
+TEST_CASE("split_nal_units: length running past the end stops cleanly") {
+  // First NAL is fine; second declares 100 bytes but only 2 remain.
+  std::vector<uint8_t> buf{
+      0x00, 0x00, 0x00, 0x01, 0xAA,
+      0x00, 0x00, 0x00, 0x64, 0xBB, 0xCC};
+  auto units = split_nal_units_4byte_length_prefixed(buf.data(), buf.size());
+  REQUIRE(units.size() == 1);
+  REQUIRE(units[0].second == 1);
+  REQUIRE(units[0].first[0] == 0xAA);
+}
+
+TEST_CASE("split_nal_units: zero-length NAL is skipped, parsing continues") {
+  std::vector<uint8_t> buf{
+      0x00, 0x00, 0x00, 0x00,                     // zero-length NAL
+      0x00, 0x00, 0x00, 0x01, 0x7E};              // 1-byte NAL
+  auto units = split_nal_units_4byte_length_prefixed(buf.data(), buf.size());
+  REQUIRE(units.size() == 1);
+  REQUIRE(units[0].second == 1);
+  REQUIRE(units[0].first[0] == 0x7E);
+}
+
+TEST_CASE("split_nal_units: a lone truncated length prefix yields nothing") {
+  std::vector<uint8_t> buf{0x00, 0x00, 0x00};  // fewer than 4 bytes
+  auto units = split_nal_units_4byte_length_prefixed(buf.data(), buf.size());
+  REQUIRE(units.empty());
+}
+
+TEST_CASE("split_nal_units: empty input yields nothing") {
+  auto units = split_nal_units_4byte_length_prefixed(nullptr, 0);
+  REQUIRE(units.empty());
+}