Commit bbf8e3ab for libheif

commit bbf8e3abd60038e8867bf2673d694313f97c6c3a
Author: Dirk Farin <dirk.farin@gmail.com>
Date:   Sat Oct 24 21:48:39 2026 +0200

    Normalize unsized AV1 sample OBUs [GHSA-jx54-6rm6-2cgj]

diff --git a/libheif/codecs/avif_boxes.cc b/libheif/codecs/avif_boxes.cc
index 859b0957..3e4a241c 100644
--- a/libheif/codecs/avif_boxes.cc
+++ b/libheif/codecs/avif_boxes.cc
@@ -269,25 +269,31 @@ Error Box_a1lx::write(StreamWriter& writer) const
 }


-static uint64_t leb128(BitReader& reader)
+static bool leb128(BitReader& reader, uint64_t* out_value)
 {
   uint64_t val = 0;
   for (int i = 0; i < 8; i++) {
+    if (reader.get_bits_remaining() < 8) {
+      return false;
+    }
+
     int64_t v = reader.get_bits(8);
     val |= (v & 0x7F) << (i * 7);
     if (!(v & 0x80)) {
-      break;
+      *out_value = val;
+      return true;
     }
   }

-  return val;
+  return false;
 }


 struct obu_header_info
 {
-  int type;
-  bool has_size;
+  int type = 0;
+  bool has_size = false;
+  bool valid = false;
   uint64_t size = 0;
 };

@@ -295,6 +301,10 @@ static obu_header_info read_obu_header_type(BitReader& reader)
 {
   obu_header_info info;

+  if (reader.get_bits_remaining() < 8) {
+    return info;
+  }
+
   reader.skip_bits(1);
   info.type = reader.get_bits(4);
   bool has_extension = reader.get_bits(1);
@@ -302,13 +312,19 @@ static obu_header_info read_obu_header_type(BitReader& reader)
   reader.skip_bits(1);

   if (has_extension) {
+    if (reader.get_bits_remaining() < 8) {
+      return info;
+    }
     reader.skip_bits(8);
   }

   if (info.has_size) {
-    info.size = leb128(reader);
+    if (!leb128(reader, &info.size)) {
+      return info;
+    }
   }

+  info.valid = true;
   return info;
 }

@@ -336,6 +352,9 @@ bool fill_av1C_configuration_from_stream(Box_av1C::configuration* out_config, co

   while (reader.get_bits_remaining() > 0) {
     obu_header_info header_info = read_obu_header_type(reader);
+    if (!header_info.valid) {
+      return false;
+    }
     if (header_info.type == HEIF_OBU_SEQUENCE_HEADER) {
       seq_header_found = true;
       break;
@@ -651,20 +670,95 @@ static bool parse_av1_seq_header_max_frame_size(BitReader& reader,
 }


-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)
+Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset)
 {
-  // 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()) {
+  if (sample_offset > data.size()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Unspecified,
+                 "AV1 stream was rejected because its sample-data offset is invalid."};
+  }
+
+  size_t pos = sample_offset;
+  while (pos < data.size()) {
+    size_t header_pos = pos++;
+    uint8_t header = data[header_pos];
+    bool has_extension = (header & 0x04) != 0;
+    bool has_size = (header & 0x02) != 0;
+
+    if (has_extension) {
+      if (pos == data.size()) {
+        return Error{heif_error_Invalid_input,
+                     heif_suberror_Unspecified,
+                     "AV1 stream was rejected because an OBU header is truncated."};
+      }
+      pos++;
+    }
+
+    if (!has_size) {
+      // AV1 ISOBMFF permits only the last OBU in a sample to omit its size. Its
+      // payload is therefore the complete sample remainder. Make that framing
+      // explicit before either the security scanner or decoder sees the bytes.
+      uint64_t payload_size = data.size() - pos;
+      std::vector<uint8_t> encoded_size;
+      do {
+        uint8_t byte = payload_size & 0x7f;
+        payload_size >>= 7;
+        if (payload_size != 0) {
+          byte |= 0x80;
+        }
+        encoded_size.push_back(byte);
+      } while (payload_size != 0);
+
+      data[header_pos] |= 0x02;
+      data.insert(data.begin() + static_cast<std::ptrdiff_t>(pos),
+                  encoded_size.begin(), encoded_size.end());
+      return Error::Ok;
+    }
+
+    uint64_t payload_size = 0;
+    bool size_complete = false;
+    for (int i = 0; i < 8 && pos < data.size(); i++) {
+      uint8_t byte = data[pos++];
+      payload_size |= static_cast<uint64_t>(byte & 0x7f) << (i * 7);
+      if ((byte & 0x80) == 0) {
+        size_complete = true;
+        break;
+      }
+    }
+
+    if (!size_complete) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "AV1 stream was rejected because an OBU size is truncated."};
+    }
+    if (payload_size > data.size() - pos) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "AV1 stream was rejected because an OBU size exceeds the remaining data."};
+    }
+
+    pos += static_cast<size_t>(payload_size);
+  }
+
+  return Error::Ok;
+}
+
+
+Result<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)
+{
+  // 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 (dataSize == 0) {
     return false;
   }
+  if (data == nullptr || dataSize > (size_t) std::numeric_limits<int>::max()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Unspecified,
+                 "AV1 stream was rejected because it cannot be scanned completely."};
+  }

   BitReader reader(data, (int) dataSize);

@@ -675,9 +769,16 @@ bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
   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;
+    if (!header.valid) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "AV1 stream was rejected because an OBU header or size is truncated."};
+    }
+
+    if (!header.has_size) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "AV1 stream was rejected because of an unknown OBU header size."};
     }

     // The reader is byte-aligned after the OBU header; this is where the payload
@@ -685,41 +786,35 @@ bool find_max_av1_frame_size_in_stream(const uint8_t* data, size_t dataSize,
     // 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 (payload_start > dataSize || header.size > dataSize - payload_start) {
+      return Error{heif_error_Invalid_input,
+                   heif_suberror_Unspecified,
+                   "AV1 stream was rejected because an OBU size exceeds the remaining data."};
     }

-    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;
-      }
+    size_t payload_len = (size_t) header.size;

+    if (header.type == HEIF_OBU_SEQUENCE_HEADER) {
       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);
+      if (!parse_av1_seq_header_max_frame_size(seq_reader, &w, &h)) {
+        return Error{heif_error_Invalid_input,
+                     heif_suberror_Unspecified,
+                     "AV1 stream was rejected because a sequence header cannot be scanned completely."};
       }
-    }

-    // 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;
+      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);
     }

-    reader.skip_bytes((uint32_t) header.size);
+    reader.skip_bytes((uint32_t) payload_len);
   }

   if (found) {
diff --git a/libheif/codecs/avif_boxes.h b/libheif/codecs/avif_boxes.h
index f59f51d4..5691df92 100644
--- a/libheif/codecs/avif_boxes.h
+++ b/libheif/codecs/avif_boxes.h
@@ -168,12 +168,19 @@ 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);
+// In an AV1 ISOBMFF sample, make a terminal OBU without a size field explicit:
+// set obu_has_size_field and insert a LEB128 size covering the sample remainder.
+// This makes the framing passed to a decoder unambiguous. All preceding OBUs
+// must carry valid sizes. `sample_offset` skips any prepended av1C configOBUs.
+Error normalize_av1_sample_data(std::vector<uint8_t>& data, size_t sample_offset);
+
+// Scan an explicitly-sized AV1 OBU stream for every OBU_SEQUENCE_HEADER and
+// return the largest coded frame size (in luma samples) declared by any of them.
+// The bool result is false when a complete scan found no sequence header, in
+// which case the size outputs are left untouched. Malformed input that prevents
+// a complete scan is returned as an error so that a partial result cannot be
+// used as a security bound.
+Result<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 64ccc8b7..fba2a3f8 100644
--- a/libheif/codecs/avif_dec.cc
+++ b/libheif/codecs/avif_dec.cc
@@ -23,6 +23,7 @@
 #include "error.h"
 #include "context.h"

+#include <algorithm>
 #include <string>


@@ -38,7 +39,15 @@ 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
+Error Decoder_AVIF::prepare_compressed_data_for_decoding(std::vector<uint8_t>& compressed_data,
+                                                         size_t configuration_data_size) const
+{
+  return normalize_av1_sample_data(compressed_data, configuration_data_size);
+}
+
+
+Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
+                                                                       size_t configuration_data_size) const
 {
   // The AV1 sequence header carries the coded frame size
   // (max_frame_width_minus_1 / max_frame_height_minus_1). Per the AVIF
@@ -47,14 +56,55 @@ Result<std::optional<ImageSize>> Decoder_AVIF::get_max_coded_image_size(const st
   // 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.
+  // `compressed_data` is the combined configOBUs + item/sample data buffer that
+  // is about to be pushed to the decoder plugin. Scan both regions for the
+  // largest sequence-header frame size so the shared decode path can reject
+  // over-limit inputs before ANY AV1 plugin allocates a frame buffer. They are
+  // scanned separately because the sample boundary must not depend on the
+  // contents of configOBUs. prepare_compressed_data_for_decoding() has already
+  // made any terminal unsized sample OBU explicit, so every OBU is now sized.
+  if (configuration_data_size > compressed_data.size()) {
+    return Error{heif_error_Invalid_input,
+                 heif_suberror_Unspecified,
+                 "AV1 stream was rejected because its configuration-data size is invalid."};
+  }
+
+  bool found = false;
   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)) {
+
+  auto scan_region = [&](size_t offset, size_t size) -> Error {
+    if (size == 0) {
+      return Error::Ok;
+    }
+
+    uint32_t width = 0;
+    uint32_t height = 0;
+    auto result = find_max_av1_frame_size_in_stream(compressed_data.data() + offset, size,
+                                                    &width, &height);
+    if (!result) {
+      return result.error();
+    }
+    if (*result) {
+      found = true;
+      max_width = std::max(max_width, width);
+      max_height = std::max(max_height, height);
+    }
+    return Error::Ok;
+  };
+
+  Error err = scan_region(0, configuration_data_size);
+  if (err) {
+    return err;
+  }
+
+  err = scan_region(configuration_data_size,
+                    compressed_data.size() - configuration_data_size);
+  if (err) {
+    return err;
+  }
+
+  if (!found) {
     // No parseable sequence header (e.g. a non-sync frame in a sequence).
     return std::optional<ImageSize>{};
   }
diff --git a/libheif/codecs/avif_dec.h b/libheif/codecs/avif_dec.h
index 3478906d..55790183 100644
--- a/libheif/codecs/avif_dec.h
+++ b/libheif/codecs/avif_dec.h
@@ -46,7 +46,11 @@ 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;
+  Error prepare_compressed_data_for_decoding(std::vector<uint8_t>& compressed_data,
+                                             size_t configuration_data_size) const override;
+
+  Result<std::optional<ImageSize>> get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
+                                                            size_t configuration_data_size) 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 76dda8ee..a938030a 100644
--- a/libheif/codecs/decoder.cc
+++ b/libheif/codecs/decoder.cc
@@ -282,8 +282,13 @@ std::shared_ptr<Decoder> Decoder::alloc_for_sequence_sample_description_box(cons
 }


-Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuration_NALs) const
+Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuration_NALs,
+                                                          size_t* out_configuration_data_size) const
 {
+  if (out_configuration_data_size) {
+    *out_configuration_data_size = 0;
+  }
+
   // --- get the compressed image data

   if (with_configuration_NALs) {
@@ -295,6 +300,9 @@ Result<std::vector<uint8_t>> Decoder::get_compressed_data(bool with_configuratio
     }

     std::vector<uint8_t> data = *confData;
+    if (out_configuration_data_size) {
+      *out_configuration_data_size = data.size();
+    }

     // append image data

@@ -470,7 +478,8 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
   // 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);
+  size_t configuration_data_size = 0;
+  auto dataResult = get_compressed_data(upload_configuration_NALs, &configuration_data_size);
   if (!dataResult) {
     return dataResult.error();
   }
@@ -486,6 +495,11 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
     };
   }

+  Error prepareErr = prepare_compressed_data_for_decoding(*dataResult, configuration_data_size);
+  if (prepareErr) {
+    return prepareErr;
+  }
+
   // 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
@@ -494,7 +508,7 @@ Error Decoder::decode_sequence_frame_from_compressed_data(bool upload_configurat
   // 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);
+  auto codedSize = get_max_coded_image_size(*dataResult, configuration_data_size);
   if (codedSize.is_error()) {
     return codedSize.error();
   }
diff --git a/libheif/codecs/decoder.h b/libheif/codecs/decoder.h
index 1a353d59..309d272f 100644
--- a/libheif/codecs/decoder.h
+++ b/libheif/codecs/decoder.h
@@ -142,7 +142,25 @@ public:
     return std::optional<ImageSize>{};
   }

-  Result<std::vector<uint8_t>> get_compressed_data(bool with_configuration_NALs) const;
+  // As above, with the length of the configuration-data prefix supplied for
+  // codecs whose framing rules differ between configuration and sample data.
+  [[nodiscard]] virtual Result<std::optional<ImageSize>>
+  get_max_coded_image_size(const std::vector<uint8_t>& compressed_data,
+                           size_t /*configuration_data_size*/) const
+  {
+    return get_max_coded_image_size(compressed_data);
+  }
+
+  // Normalize codec framing in the exact buffer that will be checked and sent
+  // to the decoder. Most codecs need no transformation.
+  virtual Error prepare_compressed_data_for_decoding(std::vector<uint8_t>& /*compressed_data*/,
+                                                     size_t /*configuration_data_size*/) const
+  {
+    return Error::Ok;
+  }
+
+  Result<std::vector<uint8_t>> get_compressed_data(bool with_configuration_NALs,
+                                                   size_t* out_configuration_data_size = nullptr) const;

   // --- decoding

diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index da4194c3..1513b6ac 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -71,6 +71,7 @@ add_libheif_test(sdtp_sample_limit)
     add_libheif_test(add_channel_checks)
     add_libheif_test(jpeg2000)
     add_libheif_test(avc_box)
+    add_libheif_test(av1_coded_size)
     add_libheif_test(hevc_sps)
     add_libheif_test(hevc_brands)
     add_libheif_test(file_layout)
diff --git a/tests/av1_coded_size.cc b/tests/av1_coded_size.cc
new file mode 100644
index 00000000..15013dbd
--- /dev/null
+++ b/tests/av1_coded_size.cc
@@ -0,0 +1,268 @@
+/*
+  libheif AV1 coded-size scan 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.
+*/
+
+#include "catch_amalgamated.hpp"
+#include "codecs/avif_boxes.h"
+#include "bitstream.h"
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+
+static void append_sequence_header(std::vector<uint8_t>& data, uint32_t width, uint32_t height)
+{
+  BitWriter writer;
+  writer.write_bits(0, 3); // seq_profile
+  writer.write_bits(1, 1); // still_picture
+  writer.write_bits(1, 1); // reduced_still_picture_header
+  writer.write_bits(0, 5); // seq_level_idx[0]
+  writer.write_bits(15, 4); // frame_width_bits_minus_1
+  writer.write_bits(15, 4); // frame_height_bits_minus_1
+  writer.write_bits(width - 1, 16);
+  writer.write_bits(height - 1, 16);
+  std::vector<uint8_t> payload = writer.get_data();
+
+  data.push_back(0x0A); // obu_type = 1, obu_has_size_field = 1
+  data.push_back(static_cast<uint8_t>(payload.size()));
+  data.insert(data.end(), payload.begin(), payload.end());
+}
+
+
+TEST_CASE("largest of several AV1 sequence headers is found")
+{
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 64, 64);
+  append_sequence_header(data, 8192, 4096);
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result);
+  REQUIRE(*result);
+  CHECK(width == 8192);
+  CHECK(height == 4096);
+}
+
+
+TEST_CASE("zero-sized AV1 OBU completes the scan")
+{
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 64, 48);
+  data.push_back(0x12); // temporal-delimiter OBU with a size field
+  data.push_back(0x00); // zero-sized payload
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result);
+  REQUIRE(*result);
+  CHECK(width == 64);
+  CHECK(height == 48);
+}
+
+
+TEST_CASE("AV1 configuration OBU without a size field is rejected")
+{
+  uint8_t unsized_obu_header = GENERATE(as<uint8_t>{}, 0x00, 0x10, 0x78);
+
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 64, 64);
+  data.push_back(unsized_obu_header);
+  append_sequence_header(data, 8192, 4096);
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result.is_error());
+  CHECK(result.error().error_code == heif_error_Invalid_input);
+  CHECK(result.error().message.find("unknown OBU header size") != std::string::npos);
+}
+
+
+TEST_CASE("terminal AV1 sample OBU gets an explicit size")
+{
+  uint8_t unsized_obu_header = GENERATE(as<uint8_t>{}, 0x10, 0x78);
+
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 64, 48);
+  size_t header_pos = data.size();
+  data.push_back(unsized_obu_header);
+  data.push_back(0xaa);
+  data.push_back(0xbb);
+
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE_FALSE(err);
+  CHECK(data[header_pos] == (unsized_obu_header | 0x02));
+  CHECK(data[header_pos + 1] == 2);
+  CHECK(data[header_pos + 2] == 0xaa);
+  CHECK(data[header_pos + 3] == 0xbb);
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result);
+  REQUIRE(*result);
+  CHECK(width == 64);
+  CHECK(height == 48);
+}
+
+
+TEST_CASE("terminal unsized AV1 sequence header remains scannable")
+{
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 320, 240);
+  REQUIRE(data[0] == 0x0a);
+  REQUIRE(data[1] < 128);
+  data[0] = 0x08;
+  data.erase(data.begin() + 1);
+
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE_FALSE(err);
+  CHECK(data[0] == 0x0a);
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result);
+  REQUIRE(*result);
+  CHECK(width == 320);
+  CHECK(height == 240);
+}
+
+
+TEST_CASE("bytes after an unsized AV1 sample OBU remain in its payload")
+{
+  std::vector<uint8_t> data;
+  append_sequence_header(data, 64, 64);
+  size_t header_pos = data.size();
+  data.push_back(0x00); // unsized reserved OBU
+  append_sequence_header(data, 8192, 4096);
+
+  size_t payload_size = data.size() - header_pos - 1;
+  REQUIRE(payload_size < 128);
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE_FALSE(err);
+  CHECK(data[header_pos] == 0x02);
+  CHECK(data[header_pos + 1] == payload_size);
+
+  // The apparent second sequence header is part of the terminal OBU payload,
+  // both for this scan and for the decoder that receives the same bytes.
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result);
+  REQUIRE(*result);
+  CHECK(width == 64);
+  CHECK(height == 64);
+}
+
+
+TEST_CASE("extended terminal AV1 sample OBU gets its size after the extension")
+{
+  std::vector<uint8_t> data{0x14, 0xab, 0xca, 0xfe};
+
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE_FALSE(err);
+  REQUIRE(data.size() == 5);
+  CHECK(data[0] == 0x16);
+  CHECK(data[1] == 0xab);
+  CHECK(data[2] == 2);
+  CHECK(data[3] == 0xca);
+  CHECK(data[4] == 0xfe);
+}
+
+
+TEST_CASE("terminal AV1 sample OBU gets a multi-byte size")
+{
+  std::vector<uint8_t> data(301, 0xaa);
+  data[0] = 0x10;
+
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE_FALSE(err);
+  REQUIRE(data.size() == 303);
+  CHECK(data[0] == 0x12);
+  CHECK(data[1] == 0xac);
+  CHECK(data[2] == 0x02);
+}
+
+
+TEST_CASE("AV1 sample normalization preserves the configuration prefix")
+{
+  std::vector<uint8_t> data{0x00, 0x10, 0xaa};
+
+  Error err = normalize_av1_sample_data(data, 1);
+  REQUIRE_FALSE(err);
+  REQUIRE(data.size() == 4);
+  CHECK(data[0] == 0x00);
+  CHECK(data[1] == 0x12);
+  CHECK(data[2] == 0x01);
+  CHECK(data[3] == 0xaa);
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), 1, &width, &height);
+  REQUIRE(result.is_error());
+  CHECK(result.error().message.find("unknown OBU header size") != std::string::npos);
+}
+
+
+TEST_CASE("malformed sized OBU prevents AV1 sample normalization")
+{
+  std::vector<uint8_t> data{0x12, 0x08};
+
+  Error err = normalize_av1_sample_data(data, 0);
+  REQUIRE(err);
+  CHECK(err.message.find("exceeds the remaining data") != std::string::npos);
+}
+
+
+TEST_CASE("truncated AV1 OBU size is rejected")
+{
+  // Sequence-header OBU followed by an unterminated LEB128 size.
+  const std::vector<uint8_t> data{0x0A, 0x80};
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result.is_error());
+  CHECK(result.error().message.find("truncated") != std::string::npos);
+}
+
+
+TEST_CASE("unparseable AV1 sequence header is rejected")
+{
+  const std::vector<uint8_t> data{0x0A, 0x00};
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result.is_error());
+  CHECK(result.error().message.find("sequence header cannot be scanned completely") != std::string::npos);
+}
+
+
+TEST_CASE("AV1 OBU extending past the input is rejected")
+{
+  // Temporal-delimiter OBU declares an eight-byte payload but supplies none.
+  const std::vector<uint8_t> data{0x12, 0x08};
+
+  uint32_t width = 0, height = 0;
+  auto result = find_max_av1_frame_size_in_stream(data.data(), data.size(), &width, &height);
+  REQUIRE(result.is_error());
+  CHECK(result.error().message.find("exceeds the remaining data") != std::string::npos);
+}
diff --git a/tests/inband_coded_size_limit.cc b/tests/inband_coded_size_limit.cc
index d3e73234..9f5fd358 100644
--- a/tests/inband_coded_size_limit.cc
+++ b/tests/inband_coded_size_limit.cc
@@ -34,8 +34,10 @@
 // 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.
+// Most files below carry a 64x64 (HEVC: 320x240) 'ispe' but an oversized coded
+// size in the bitstream, and must be rejected with a security-limit error. The
+// unsized-OBU AVIF instead verifies that its apparent second picture remains
+// payload of the terminal OBU and is never decoded.

 #include "catch_amalgamated.hpp"
 #include "libheif/heif.h"
@@ -100,6 +102,37 @@ void expect_security_reject(heif_compression_format format, const char* b64) {
   heif_context_free(ctx);
 }

+// Assert that normalizing a terminal unsized OBU prevents a decoder from
+// resynchronizing on bytes inside that OBU's payload.
+void expect_safe_av1_decode(const char* b64) {
+  if (!heif_have_decoder_for_format(heif_compression_AV1)) {
+    SKIP("no AV1 decoder 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_Ok);
+  REQUIRE(img != nullptr);
+  CHECK(heif_image_get_primary_width(img) == 64);
+  CHECK(heif_image_get_primary_height(img) == 64);
+
+  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.
@@ -148,6 +181,22 @@ const char* kAvifPocB64 =
   "mLGOwbZyMlmA0easG5s7ljg1k8Hjl6lwngAo89cMZZgd5Q0mv6aYsY7BtnIyWYDR5qwbmzuWODWTweOXqXCe"
 ;

+// AVIF item with a 64x64 ispe and picture, followed by a zero byte and bytes
+// encoding a complete 8192x8192 picture. The zero byte is a terminal OBU header
+// without a size field, so all remaining bytes are its payload. Unnormalized,
+// some decoders skip that header and continue with the apparent second picture.
+const char* kAvifUnknownObuSizeB64 =
+  "AAAAHGZ0eXBhdmlmAAAAAG1pZjFhdmlmbWlhZgAAANBtZXRhAAAAAAAAACFoZGxyAAAAAAAAAABwaWN0AAAAAAAAAAAAAAAAAAAA"
+  "AA5waXRtAAAAAAABAAAAHmlsb2MAAAAARAAAAQABAAAAAQAAAPQAAAHaAAAAI2lpbmYAAAAAAAEAAAAVaW5mZQIAAAAAAQAAYXYw"
+  "MQAAAABUaXBycAAAADZpcGNvAAAADGF2MUOBABwAAAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAOcGl4aQAAAAABCAAAABZpcG1hAAAA"
+  "AAAAAAEAAQOBAgMAAAHibWRhdBIACgYYFX/9hUAyCRgAKKEAvQSfQAASAAoHH/M////+FTK2AxgIACihAAA09cchURGzwfjl07r5"
+  "Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7O"
+  "bKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw0"
+  "9cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpY"
+  "j1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL"
+  "9/B5D0mr/b/3RZw09cchURGzwfjl07r5Ic88I4pqhhpYj1YI07VQ0BKJY+7ObKwfvTSKh+JL9/B5D0mr/b/3RZz1xyFREbPB+OXT"
+  "uvkhzzwjimqGGliPVgjTtVDQEolj7s5srB+9NIqH4kv38HkPSav9v/dFnA==";
+
 } // namespace


@@ -155,6 +204,10 @@ TEST_CASE("avif: reject in-band AV1 sequence header exceeding the security limit
   expect_security_reject(heif_compression_AV1, kAvifPocB64);
 }

+TEST_CASE("avif: terminal unsized OBU prevents decoder resynchronization") {
+  expect_safe_av1_decode(kAvifUnknownObuSizeB64);
+}
+
 TEST_CASE("hevc: reject in-band SPS exceeding the security limit") {
   expect_security_reject(heif_compression_HEVC, kHevcInbandB64);
 }