Reserve capped array capacity in json_sax_dom_parser::start_array() for definite-length binary arrays (#5476)

* Reserve capped array capacity for definite-length binary arrays

CBOR, MessagePack, and the optimized [$type#count UBJSON/BJData form all
pass an exact element count to sax->start_array(len), but
json_sax_dom_parser::start_array() (and the callback variant) only used
len for an overflow check against max_size() and never reserved the
underlying vector, so each element triggered a reallocation cascade via
emplace_back().

Reserve upfront, but cap the reservation at 16384 elements: max_size()
for a std::vector is far larger than any realistic input, so an
unbounded reserve(len) would let a crafted/truncated header (e.g. CBOR
0x9A + a huge uint32 count with no data) trigger a multi-gigabyte
allocation attempt instead of the normal graceful parse_error. With the
cap, a hostile length still fails fast with the existing parse_error,
while realistic arrays get a single up-front allocation.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Make the huge-claimed-length DoS regression tests portable across size_t widths

On a platform where size_t is narrower than 64 bits (e.g. 32-bit mingw/msvc
x86), the previously-hardcoded huge test lengths either collide with that
platform's unknown_size() sentinel (CBOR/MessagePack, both using exactly
SIZE_MAX) or exceed the platform's smaller vector<json>::max_size()
(UBJSON/BJData's 0x7FFFFFFF), so the header is now rejected outright
(out_of_range.408) instead of being accepted and only found short of data
(parse_error.110). Both are safe, bounded rejections of the hostile input;
the property under test -- no attempt to allocate space for billions of
elements -- holds either way. Accept both outcomes instead of pinning the
64-bit-only exact result.

Also fixed an unrelated clang-tidy finding (google-readability-casting) on
the functional-style std::size_t(...) casts in the neighboring "arrays of
various sizes" section.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Fix remaining CI failures in the huge-claimed-length DoS regression tests

- Apply the same google-readability-casting fix (std::size_t{N} instead
  of std::size_t(N)) to the "arrays of various sizes" section in
  unit-msgpack.cpp, unit-ubjson.cpp, and unit-bjdata.cpp; only
  unit-cbor.cpp had been fixed previously, since clang-tidy's build
  didn't get far enough to report the other three in the same pass.

- json_sax_dom_parser::start_array()'s max_size() check calls JSON_THROW
  directly rather than going through sax->parse_error(), so unlike the
  scanner's own "not enough data" parse_error it is not gated by
  allow_exceptions=false. On a platform where a header's claimed count
  exceeds max_size() (e.g. 32-bit, for UBJSON/BJData's 0x7FFFFFFF test
  value), from_ubjson/from_bjdata(input, true, false) can therefore still
  throw instead of returning a discarded value. Make that assertion
  tolerant of either outcome, same as the main exception-catching check
  above it.

- Guard all four "a huge claimed length..." SECTIONs with
  #if !defined(JSON_NOEXCEPTION), matching this test suite's existing
  convention for exception-dependent tests: under JSON_NOEXCEPTION,
  JSON_THROW never produces a catchable C++ exception at all (it aborts
  the process), so a section that relies on try/catch to distinguish
  between two acceptable outcomes cannot be expressed under that build
  configuration regardless of platform.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Use (std::min)(len, reserve_cap) instead of a ternary in start_array()

Addresses review feedback from @gregmarr on PR #5476.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
diff --git a/include/nlohmann/detail/input/json_sax.hpp b/include/nlohmann/detail/input/json_sax.hpp
index 1627d23..60c468f 100644
--- a/include/nlohmann/detail/input/json_sax.hpp
+++ b/include/nlohmann/detail/input/json_sax.hpp
@@ -8,6 +8,7 @@
 
 #pragma once
 
+#include <algorithm> // min
 #include <cstddef>
 #include <string> // string
 #include <type_traits> // enable_if_t
@@ -305,6 +306,16 @@
             JSON_THROW(out_of_range::create(408, concat("excessive array size: ", std::to_string(len)), ref_stack.back()));
         }
 
+        if (len != detail::unknown_size())
+        {
+            // reserve upfront to avoid repeated reallocations while adding elements,
+            // but cap the reservation so a bogus/hostile length (which is not bounded
+            // by max_size(), unlike e.g. std::vector) cannot trigger an oversized
+            // allocation for a small or truncated input
+            constexpr std::size_t reserve_cap = 16384;
+            ref_stack.back()->m_data.m_value.array->reserve((std::min)(len, reserve_cap));
+        }
+
         return true;
     }
 
@@ -683,6 +694,16 @@
             {
                 JSON_THROW(out_of_range::create(408, concat("excessive array size: ", std::to_string(len)), ref_stack.back()));
             }
+
+            if (len != detail::unknown_size())
+            {
+                // reserve upfront to avoid repeated reallocations while adding elements,
+                // but cap the reservation so a bogus/hostile length (which is not bounded
+                // by max_size(), unlike e.g. std::vector) cannot trigger an oversized
+                // allocation for a small or truncated input
+                constexpr std::size_t reserve_cap = 16384;
+                ref_stack.back()->m_data.m_value.array->reserve((std::min)(len, reserve_cap));
+            }
         }
 
         return true;
diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp
index 3b2233a..6eb006b 100644
--- a/single_include/nlohmann/json.hpp
+++ b/single_include/nlohmann/json.hpp
@@ -7892,6 +7892,7 @@
 
 
 
+#include <algorithm> // min
 #include <cstddef>
 #include <string> // string
 #include <type_traits> // enable_if_t
@@ -11018,6 +11019,16 @@
             JSON_THROW(out_of_range::create(408, concat("excessive array size: ", std::to_string(len)), ref_stack.back()));
         }
 
+        if (len != detail::unknown_size())
+        {
+            // reserve upfront to avoid repeated reallocations while adding elements,
+            // but cap the reservation so a bogus/hostile length (which is not bounded
+            // by max_size(), unlike e.g. std::vector) cannot trigger an oversized
+            // allocation for a small or truncated input
+            constexpr std::size_t reserve_cap = 16384;
+            ref_stack.back()->m_data.m_value.array->reserve((std::min)(len, reserve_cap));
+        }
+
         return true;
     }
 
@@ -11396,6 +11407,16 @@
             {
                 JSON_THROW(out_of_range::create(408, concat("excessive array size: ", std::to_string(len)), ref_stack.back()));
             }
+
+            if (len != detail::unknown_size())
+            {
+                // reserve upfront to avoid repeated reallocations while adding elements,
+                // but cap the reservation so a bogus/hostile length (which is not bounded
+                // by max_size(), unlike e.g. std::vector) cannot trigger an oversized
+                // allocation for a small or truncated input
+                constexpr std::size_t reserve_cap = 16384;
+                ref_stack.back()->m_data.m_value.array->reserve((std::min)(len, reserve_cap));
+            }
         }
 
         return true;
diff --git a/tests/src/unit-bjdata.cpp b/tests/src/unit-bjdata.cpp
index 3130bb7..9338e66 100644
--- a/tests/src/unit-bjdata.cpp
+++ b/tests/src/unit-bjdata.cpp
@@ -3551,6 +3551,111 @@
     }
 }
 
+TEST_CASE("issue #5405 - array reserve for definite-length BJData arrays")
+{
+#if !defined(JSON_NOEXCEPTION)
+    // this SECTION relies on catching a thrown exception to distinguish
+    // which of two acceptable, bounded rejections a hostile header took;
+    // under JSON_NOEXCEPTION, JSON_THROW never produces a catchable C++
+    // exception (it aborts instead), so this cannot be tested that way here
+    SECTION("a huge claimed length with no element data must not over-allocate")
+    {
+        // optimized form [$type#count: type 'i' (int8), count as a four-byte
+        // little-endian 'l' (int32) of 0x7FFFFFFF (2147483647), but no
+        // element data at all. max_size() for a std::vector is far larger
+        // than this count, so it does not reject the header outright; the
+        // (capped) reservation must not attempt to allocate space for
+        // billions of elements before the missing data is detected.
+        json _;
+        const std::vector<uint8_t> input = {'[', '$', 'i', '#', 'l', 0xFF, 0xFF, 0xFF, 0x7F};
+        // On a platform where std::vector<json>::max_size() is smaller than
+        // the claimed count (e.g. 32-bit, where max_size() is bounded by a
+        // 32-bit SIZE_MAX divided by sizeof(json)), the SAX consumer's own
+        // check rejects the header outright (out_of_range.408, with the
+        // claimed count in the message) instead of accepting it and only
+        // finding it short of data once the (capped) reservation looks for
+        // element bytes that were never provided (parse_error.110). Either
+        // is an acceptable, bounded rejection of the hostile header -- the
+        // property under test is that no path attempts to allocate space
+        // for billions of elements.
+        bool threw = false;
+        try
+        {
+            _ = json::from_bjdata(input);
+        }
+        catch (const json::parse_error& e)
+        {
+            threw = true;
+            CHECK(e.id == 110);
+            CHECK(std::string(e.what()) == "[json.exception.parse_error.110] parse error at byte 10: syntax error while parsing BJData number: unexpected end of input");
+        }
+        catch (const json::out_of_range& e)
+        {
+            threw = true;
+            CHECK(e.id == 408);
+            CHECK(std::string(e.what()).find("excessive array size") != std::string::npos);
+        }
+        CHECK(threw);
+
+        // json_sax_dom_parser::start_array()'s max_size() check (unlike the
+        // scanner's own parse_error path) throws unconditionally via
+        // JSON_THROW rather than going through sax->parse_error(), so it is
+        // not gated by allow_exceptions=false on a platform where this
+        // header hits that check (e.g. 32-bit, see above) -- allow either
+        // a discarded result or the same out_of_range it throws with
+        // exceptions enabled.
+        try
+        {
+            CHECK(json::from_bjdata(input, true, false).is_discarded());
+        }
+        catch (const json::out_of_range& e)
+        {
+            CHECK(e.id == 408);
+        }
+    }
+#endif
+
+    SECTION("arrays of various sizes decode to the same value as before the reserve optimization")
+    {
+        for (const auto size :
+                {
+                    std::size_t{0}, std::size_t{1}, std::size_t{5}, // small
+                    std::size_t{16384},                             // exactly at the reserve cap
+                    std::size_t{20000}                              // above the reserve cap
+                })
+        {
+            CAPTURE(size)
+            json j = json::array();
+            for (std::size_t i = 0; i < size; ++i)
+            {
+                j.push_back(static_cast<int>(i % 1000));
+            }
+
+            // exercise both the plain and the optimized [$type#count encoding
+            const auto packed_plain = json::to_bjdata(j);
+            CHECK(json::from_bjdata(packed_plain) == j);
+
+            const auto packed_optimized = json::to_bjdata(j, true, true);
+            CHECK(json::from_bjdata(packed_optimized) == j);
+        }
+    }
+
+    SECTION("a user-defined SAX consumer is unaffected by the internal DOM reserve optimization")
+    {
+        // the reserve() call is local to json_sax_dom_parser / json_sax_dom_callback_parser;
+        // a custom SAX consumer that does not touch a DOM array sees identical events
+        json j = json::array();
+        for (int i = 0; i < 100; ++i)
+        {
+            j.push_back(i);
+        }
+        const auto packed = json::to_bjdata(j, true, true);
+
+        SaxCountdown scp(1000000); // large enough to never trigger an abort
+        CHECK(json::sax_parse(packed, &scp, json::input_format_t::bjdata));
+    }
+}
+
 TEST_CASE("Universal Binary JSON Specification Examples 1")
 {
     SECTION("Null Value")
diff --git a/tests/src/unit-cbor.cpp b/tests/src/unit-cbor.cpp
index 032e664..4c91075 100644
--- a/tests/src/unit-cbor.cpp
+++ b/tests/src/unit-cbor.cpp
@@ -2174,6 +2174,92 @@
     }
 }
 
+TEST_CASE("issue #5405 - array reserve for definite-length CBOR arrays")
+{
+#if !defined(JSON_NOEXCEPTION)
+    // this SECTION relies on catching a thrown exception to distinguish
+    // which of two acceptable, bounded rejections a hostile header took;
+    // under JSON_NOEXCEPTION, JSON_THROW never produces a catchable C++
+    // exception (it aborts instead), so this cannot be tested that way here
+    SECTION("a huge claimed length with no element data must not over-allocate")
+    {
+        // 0x9A: array with a four-byte length; claims 0xFFFFFFFF (4294967295)
+        // elements but provides none. max_size() for a std::vector is far
+        // larger than this count, so it does not reject the header outright;
+        // the (capped) reservation must not attempt to allocate space for
+        // billions of elements before the missing data is detected.
+        json _;
+        const std::vector<uint8_t> input = {0x9A, 0xFF, 0xFF, 0xFF, 0xFF};
+        // On a platform where std::size_t is narrower than 64 bits (e.g.
+        // 32-bit), the claimed count 0xFFFFFFFF coincides with that
+        // platform's detail::unknown_size() sentinel (SIZE_MAX), so the
+        // format-level size check rejects it outright (out_of_range.408,
+        // "excessive ... size") before the SAX consumer's own max_size()
+        // check would even run; on a 64-bit platform it passes both of
+        // those checks and is only found short of data once the (capped)
+        // reservation looks for element bytes that were never provided
+        // (parse_error.110). Either is an acceptable, bounded rejection of
+        // the hostile header -- the property under test is that no path
+        // attempts to allocate space for billions of elements.
+        bool threw = false;
+        try
+        {
+            _ = json::from_cbor(input);
+        }
+        catch (const json::parse_error& e)
+        {
+            threw = true;
+            CHECK(e.id == 110);
+            CHECK(std::string(e.what()) == "[json.exception.parse_error.110] parse error at byte 6: syntax error while parsing CBOR value: unexpected end of input");
+        }
+        catch (const json::out_of_range& e)
+        {
+            threw = true;
+            CHECK(e.id == 408);
+            CHECK(std::string(e.what()).find("excessive") != std::string::npos);
+        }
+        CHECK(threw);
+        CHECK(json::from_cbor(input, true, false).is_discarded());
+    }
+#endif
+
+    SECTION("arrays of various sizes decode to the same value as before the reserve optimization")
+    {
+        for (const auto size :
+                {
+                    std::size_t{0}, std::size_t{1}, std::size_t{5}, // small
+                    std::size_t{16384},                             // exactly at the reserve cap
+                    std::size_t{20000}                              // above the reserve cap
+                })
+        {
+            CAPTURE(size)
+            json j = json::array();
+            for (std::size_t i = 0; i < size; ++i)
+            {
+                j.push_back(static_cast<int>(i % 1000));
+            }
+
+            const auto packed = json::to_cbor(j);
+            CHECK(json::from_cbor(packed) == j);
+        }
+    }
+
+    SECTION("a user-defined SAX consumer is unaffected by the internal DOM reserve optimization")
+    {
+        // the reserve() call is local to json_sax_dom_parser / json_sax_dom_callback_parser;
+        // a custom SAX consumer that does not touch a DOM array sees identical events
+        json j = json::array();
+        for (int i = 0; i < 100; ++i)
+        {
+            j.push_back(i);
+        }
+        const auto packed = json::to_cbor(j);
+
+        SaxCountdown scp(1000000); // large enough to never trigger an abort
+        CHECK(json::sax_parse(packed, &scp, json::input_format_t::cbor));
+    }
+}
+
 TEST_CASE("CBOR roundtrips" * doctest::skip())
 {
     SECTION("input from flynn")
diff --git a/tests/src/unit-msgpack.cpp b/tests/src/unit-msgpack.cpp
index 75c2ae4..74f7f49 100644
--- a/tests/src/unit-msgpack.cpp
+++ b/tests/src/unit-msgpack.cpp
@@ -1597,6 +1597,91 @@
     }
 }
 
+TEST_CASE("issue #5405 - array reserve for definite-length MessagePack arrays")
+{
+#if !defined(JSON_NOEXCEPTION)
+    // this SECTION relies on catching a thrown exception to distinguish
+    // which of two acceptable, bounded rejections a hostile header took;
+    // under JSON_NOEXCEPTION, JSON_THROW never produces a catchable C++
+    // exception (it aborts instead), so this cannot be tested that way here
+    SECTION("a huge claimed length with no element data must not over-allocate")
+    {
+        // 0xdd: array 32 (four-byte length); claims 0xFFFFFFFF (4294967295)
+        // elements but provides none. max_size() for a std::vector is far
+        // larger than this count, so it does not reject the header outright;
+        // the (capped) reservation must not attempt to allocate space for
+        // billions of elements before the missing data is detected.
+        json _;
+        const std::vector<uint8_t> input = {0xdd, 0xFF, 0xFF, 0xFF, 0xFF};
+        // On a platform where std::size_t is narrower than 64 bits (e.g.
+        // 32-bit), the claimed count 0xFFFFFFFF coincides with that
+        // platform's SIZE_MAX, which some size-narrowing checks treat the
+        // same as detail::unknown_size(); it may then be rejected before
+        // the SAX consumer's own max_size() check (out_of_range.408) rather
+        // than being accepted and only found short of data once the
+        // (capped) reservation looks for element bytes that were never
+        // provided (parse_error.110). Either is an acceptable, bounded
+        // rejection of the hostile header -- the property under test is
+        // that no path attempts to allocate space for billions of elements.
+        bool threw = false;
+        try
+        {
+            _ = json::from_msgpack(input);
+        }
+        catch (const json::parse_error& e)
+        {
+            threw = true;
+            CHECK(e.id == 110);
+            CHECK(std::string(e.what()) == "[json.exception.parse_error.110] parse error at byte 6: syntax error while parsing MessagePack value: unexpected end of input");
+        }
+        catch (const json::out_of_range& e)
+        {
+            threw = true;
+            CHECK(e.id == 408);
+            CHECK(std::string(e.what()).find("excessive") != std::string::npos);
+        }
+        CHECK(threw);
+        CHECK(json::from_msgpack(input, true, false).is_discarded());
+    }
+#endif
+
+    SECTION("arrays of various sizes decode to the same value as before the reserve optimization")
+    {
+        for (const auto size :
+                {
+                    std::size_t{0}, std::size_t{1}, std::size_t{5}, // small
+                    std::size_t{16384},                             // exactly at the reserve cap
+                    std::size_t{20000}                              // above the reserve cap
+                })
+        {
+            CAPTURE(size)
+            json j = json::array();
+            for (std::size_t i = 0; i < size; ++i)
+            {
+                j.push_back(static_cast<int>(i % 1000));
+            }
+
+            const auto packed = json::to_msgpack(j);
+            CHECK(json::from_msgpack(packed) == j);
+        }
+    }
+
+    SECTION("a user-defined SAX consumer is unaffected by the internal DOM reserve optimization")
+    {
+        // the reserve() call is local to json_sax_dom_parser / json_sax_dom_callback_parser;
+        // a custom SAX consumer that does not touch a DOM array sees identical events
+        json j = json::array();
+        for (int i = 0; i < 100; ++i)
+        {
+            j.push_back(i);
+        }
+        const auto packed = json::to_msgpack(j);
+
+        SaxCountdown scp(1000000); // large enough to never trigger an abort
+        CHECK(json::sax_parse(packed, &scp, json::input_format_t::msgpack));
+    }
+}
+
 // use this testcase outside [hide] to run it with Valgrind
 TEST_CASE("MessagePack nesting does not consume the call stack")
 {
diff --git a/tests/src/unit-ubjson.cpp b/tests/src/unit-ubjson.cpp
index 2ebb55b..aafbbf5 100644
--- a/tests/src/unit-ubjson.cpp
+++ b/tests/src/unit-ubjson.cpp
@@ -2315,6 +2315,112 @@
     }
 }
 
+TEST_CASE("issue #5405 - array reserve for definite-length UBJSON arrays")
+{
+#if !defined(JSON_NOEXCEPTION)
+    // this SECTION relies on catching a thrown exception to distinguish
+    // which of two acceptable, bounded rejections a hostile header took;
+    // under JSON_NOEXCEPTION, JSON_THROW never produces a catchable C++
+    // exception (it aborts instead), so this cannot be tested that way here
+    SECTION("a huge claimed length with no element data must not over-allocate")
+    {
+        // optimized form [$type#count: type 'i' (int8), count as a four-byte
+        // 'l' (int32) of 0x7FFFFFFF (2147483647), but no element data at all.
+        // max_size() for a std::vector is far larger than this count, so it
+        // does not reject the header outright; the (capped) reservation must
+        // not attempt to allocate space for billions of elements before the
+        // missing data is detected.
+        json _;
+        const std::vector<uint8_t> input = {'[', '$', 'i', '#', 'l', 0x7F, 0xFF, 0xFF, 0xFF};
+        // On a platform where std::vector<json>::max_size() is smaller than
+        // the claimed count (e.g. 32-bit, where max_size() is bounded by a
+        // 32-bit SIZE_MAX divided by sizeof(json)), the SAX consumer's own
+        // check rejects the header outright (out_of_range.408, with the
+        // claimed count in the message) instead of accepting it and only
+        // finding it short of data once the (capped) reservation looks for
+        // element bytes that were never provided (parse_error.110). Either
+        // is an acceptable, bounded rejection of the hostile header -- the
+        // property under test is that no path attempts to allocate space
+        // for billions of elements.
+        bool threw = false;
+        try
+        {
+            _ = json::from_ubjson(input);
+        }
+        catch (const json::parse_error& e)
+        {
+            threw = true;
+            CHECK(e.id == 110);
+            CHECK(std::string(e.what()) == "[json.exception.parse_error.110] parse error at byte 10: syntax error while parsing UBJSON number: unexpected end of input");
+        }
+        catch (const json::out_of_range& e)
+        {
+            threw = true;
+            CHECK(e.id == 408);
+            CHECK(std::string(e.what()).find("excessive array size") != std::string::npos);
+        }
+        CHECK(threw);
+
+        // json_sax_dom_parser::start_array()'s max_size() check (unlike the
+        // scanner's own parse_error path) throws unconditionally via
+        // JSON_THROW rather than going through sax->parse_error(), so it is
+        // not gated by allow_exceptions=false on a platform where this
+        // header hits that check (e.g. 32-bit, see above) -- allow either
+        // a discarded result or the same out_of_range it throws with
+        // exceptions enabled.
+        try
+        {
+            CHECK(json::from_ubjson(input, true, false).is_discarded());
+        }
+        catch (const json::out_of_range& e)
+        {
+            CHECK(e.id == 408);
+        }
+    }
+#endif
+
+    SECTION("arrays of various sizes decode to the same value as before the reserve optimization")
+    {
+        for (const auto size :
+                {
+                    std::size_t{0}, std::size_t{1}, std::size_t{5}, // small
+                    std::size_t{16384},                             // exactly at the reserve cap
+                    std::size_t{20000}                              // above the reserve cap
+                })
+        {
+            CAPTURE(size)
+            json j = json::array();
+            for (std::size_t i = 0; i < size; ++i)
+            {
+                j.push_back(static_cast<int>(i % 1000));
+            }
+
+            // exercise both the plain and the optimized [$type#count encoding
+            const auto packed_plain = json::to_ubjson(j);
+            CHECK(json::from_ubjson(packed_plain) == j);
+
+            const auto packed_optimized = json::to_ubjson(j, true, true);
+            CHECK(json::from_ubjson(packed_optimized) == j);
+        }
+    }
+
+    SECTION("a user-defined SAX consumer is unaffected by the internal DOM reserve optimization")
+    {
+        // the reserve() call is local to json_sax_dom_parser / json_sax_dom_callback_parser;
+        // a custom SAX consumer that does not touch a DOM array sees identical events
+        json j = json::array();
+        for (int i = 0; i < 100; ++i)
+        {
+            j.push_back(i);
+        }
+        const auto packed = json::to_ubjson(j, true, true);
+
+        SaxCountdown scp(1000000); // large enough to never trigger an abort
+        CHECK(json::sax_parse(packed, &scp, json::input_format_t::ubjson));
+    }
+}
+
+
 TEST_CASE("Universal Binary JSON Specification Examples 1")
 {
     SECTION("Null Value")