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