Compare commits

..
Author SHA1 Message Date
Niels Lohmann ab8aa8458b Fix 32-bit overflow in huge_string_t BSON length-overflow tests
huge_string_t doubles as basic_json's StringType, so it is used not only
for the JSON string value under test but also for object keys (e.g. "s",
"nested"). Making size() unconditionally lie about being huge therefore
inflated the keys' reported sizes as well, pushing the running totals
computed while walking the BSON document (calc_bson_object_size and
friends in binary_writer.hpp) past what a 32-bit std::size_t can hold.

On 64-bit platforms this happens to still produce a working (if
needlessly large) result, but on 32-bit platforms (e.g. the mingw x86 CI
job) the size_t arithmetic silently wraps around: for the "document" test
this merely surfaces the wrong number in the exception message, but for
the "string" test the wrapped total happens to fall back under
INT32_MAX, so the intended out_of_range.412 guard is skipped entirely and
the code goes on to actually write ~2 GiB worth of characters from the
key's real, tiny buffer - which is what raised the reported
"vector::_M_range_insert" exception instead of a controlled 412.

Make the fake-huge size opt-in via huge_string_t::as_huge() and only
apply it to the string value under test, leaving keys at their real
(small) size. This keeps every intermediate size well within 32-bit
size_t range on any platform, matching how huge_binary_t already avoids
the same trap (it is only ever used as the BSON value type, never as a
key). Expected out_of_range.412 messages are updated accordingly.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-09-06 11:32:21 +02:00
Niels Lohmann a33e015e39 Add test coverage for documented lenient BSON input handling
Issue #5333 documented three intentionally-lenient behaviors of the BSON
reader (any non-zero byte accepted as a boolean `true`, BSON array element
keys not validated against the required decimal sequence, and the payload
of binary subtype 0x02 "old binary" returned as-is including its inner
length prefix), but none of them was pinned by a test, so a future change
could silently regress the documented behavior.

Also add coverage for the out_of_range.412 length-overflow check
(shared by binary, string, and (sub-)document BSON length fields) for
the string and document cases; only the binary case was previously
tested.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-09-05 20:52:05 +02:00
4 changed files with 146 additions and 138 deletions
+2 -14
View File
@@ -71,7 +71,7 @@ class serializer
, thousands_sep(loc->thousands_sep == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->thousands_sep))) , thousands_sep(loc->thousands_sep == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->thousands_sep)))
, decimal_point(loc->decimal_point == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->decimal_point))) , decimal_point(loc->decimal_point == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->decimal_point)))
, indent_char(ichar) , indent_char(ichar)
, indent_string() , indent_string(512, indent_char)
, error_handler(error_handler_) , error_handler(error_handler_)
{} {}
@@ -126,10 +126,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -203,10 +199,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -268,10 +260,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -1022,7 +1010,7 @@ class serializer
/// the indentation character /// the indentation character
const char indent_char; const char indent_char;
/// the indentation string (lazily allocated on first use by a pretty-print branch) /// the indentation string
string_t indent_string; string_t indent_string;
/// error_handler how to react on decoding errors /// error_handler how to react on decoding errors
+2 -14
View File
@@ -20150,7 +20150,7 @@ class serializer
, thousands_sep(loc->thousands_sep == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->thousands_sep))) , thousands_sep(loc->thousands_sep == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->thousands_sep)))
, decimal_point(loc->decimal_point == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->decimal_point))) , decimal_point(loc->decimal_point == nullptr ? '\0' : std::char_traits<char>::to_char_type(* (loc->decimal_point)))
, indent_char(ichar) , indent_char(ichar)
, indent_string() , indent_string(512, indent_char)
, error_handler(error_handler_) , error_handler(error_handler_)
{} {}
@@ -20205,10 +20205,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -20282,10 +20278,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -20347,10 +20339,6 @@ class serializer
// variable to hold indentation for recursive calls // variable to hold indentation for recursive calls
const auto new_indent = current_indent + indent_step; const auto new_indent = current_indent + indent_step;
if (JSON_HEDLEY_UNLIKELY(indent_string.empty()))
{
indent_string.resize(512, indent_char);
}
if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent)) if (JSON_HEDLEY_UNLIKELY(indent_string.size() < new_indent))
{ {
indent_string.resize(indent_string.size() * 2, ' '); indent_string.resize(indent_string.size() * 2, ' ');
@@ -21101,7 +21089,7 @@ class serializer
/// the indentation character /// the indentation character
const char indent_char; const char indent_char;
/// the indentation string (lazily allocated on first use by a pretty-print branch) /// the indentation string
string_t indent_string; string_t indent_string;
/// error_handler how to react on decoding errors /// error_handler how to react on decoding errors
+142 -3
View File
@@ -38,6 +38,54 @@ class huge_binary_t : public std::vector<std::uint8_t>
using huge_binary_json = nlohmann::basic_json < using huge_binary_json = nlohmann::basic_json <
std::map, std::vector, std::string, bool, std::int64_t, std::uint64_t, std::map, std::vector, std::string, bool, std::int64_t, std::uint64_t,
double, std::allocator, nlohmann::adl_serializer, huge_binary_t, void >; double, std::allocator, nlohmann::adl_serializer, huge_binary_t, void >;
// a string type that can be made to report a size beyond INT32_MAX without
// allocating that much memory, so BSON length overflow can be tested for
// strings and (embedded) documents as well, following the same idea as
// huge_binary_t.
//
// Unlike huge_binary_t (which is only ever used as the BSON *value* type),
// this type doubles as basic_json's StringType and is therefore also used
// for *object keys* (e.g. "s" or "nested" below). Only the designated test
// value is meant to lie about its size - if every huge_string_t (including
// keys) reported a huge size, the running totals computed while walking the
// BSON document (see calc_bson_object_size & friends in binary_writer.hpp)
// would need more than 32 bits, and on platforms where std::size_t is only
// 32 bits wide that arithmetic would silently wrap around, producing wrong
// (or even unguarded) lengths. The fake size is therefore opt-in via
// as_huge(), and plain strings - in particular object keys - keep reporting
// their real, small size.
class huge_string_t : public std::string
{
public:
using std::string::string;
huge_string_t(const std::string& s) : std::string(s) {} // NOLINT(google-explicit-constructor,hicpp-explicit-conversions)
// returns a copy of @a s whose size() pretends to be huge
static huge_string_t as_huge(const std::string& s)
{
huge_string_t result(s);
result.pretend_huge = true;
return result;
}
size_type size() const noexcept
{
if (pretend_huge)
{
// one byte more than the BSON length field can represent
return static_cast<size_type>((std::numeric_limits<std::int32_t>::max)()) + 1;
}
return std::string::size();
}
private:
bool pretend_huge = false;
};
using huge_string_json = nlohmann::basic_json <
std::map, std::vector, huge_string_t, bool, std::int64_t, std::uint64_t,
double, std::allocator, nlohmann::adl_serializer, std::vector<std::uint8_t>, void >;
} // namespace } // namespace
TEST_CASE("BSON") TEST_CASE("BSON")
@@ -105,10 +153,36 @@ TEST_CASE("BSON")
SECTION("lengths exceeding INT32_MAX cannot be serialized to BSON") SECTION("lengths exceeding INT32_MAX cannot be serialized to BSON")
{ {
huge_binary_json j; // out_of_range.412 is thrown from a single shared helper
j["b"] = huge_binary_json::binary(huge_binary_t{}); // (to_bson_length) that guards the BSON length fields of binary
// values, strings, and (embedded) documents alike
SECTION("binary")
{
huge_binary_json j;
j["b"] = huge_binary_json::binary(huge_binary_t{});
CHECK_THROWS_WITH_AS(huge_binary_json::to_bson(j), "[json.exception.out_of_range.412] BSON length 2147483661 exceeds maximum of 2147483647", huge_binary_json::out_of_range&); CHECK_THROWS_WITH_AS(huge_binary_json::to_bson(j), "[json.exception.out_of_range.412] BSON length 2147483661 exceeds maximum of 2147483647", huge_binary_json::out_of_range&);
}
SECTION("string")
{
huge_string_json j;
j["s"] = huge_string_t::as_huge("value");
CHECK_THROWS_WITH_AS(huge_string_json::to_bson(j), "[json.exception.out_of_range.412] BSON length 2147483661 exceeds maximum of 2147483647", huge_string_json::out_of_range&);
}
SECTION("document")
{
// an oversized string nested one level deep makes the
// *embedded* document's own length exceed INT32_MAX as well
huge_string_json nested;
nested["s"] = huge_string_t::as_huge("value");
huge_string_json j;
j["nested"] = nested;
CHECK_THROWS_WITH_AS(huge_string_json::to_bson(j), "[json.exception.out_of_range.412] BSON length 2147483674 exceeds maximum of 2147483647", huge_string_json::out_of_range&);
}
} }
SECTION("string length must be at least 1") SECTION("string length must be at least 1")
@@ -193,6 +267,23 @@ TEST_CASE("BSON")
CHECK(json::from_bson(result, true, false) == j); CHECK(json::from_bson(result, true, false) == j);
} }
SECTION("non-empty object with bool from a non-0/1 byte (lenient parsing)")
{
// documented lenient behavior (see gh-5333): any non-zero byte
// is accepted as `true`, not just 0x01
std::vector<std::uint8_t> const input =
{
0x0D, 0x00, 0x00, 0x00, // size (little endian)
0x08, // entry: boolean
'e', 'n', 't', 'r', 'y', '\x00',
0x02, // value = 0x02 (neither 0x00 nor 0x01)
0x00 // end marker
};
const json expected = { { "entry", true } };
CHECK(json::from_bson(input) == expected);
}
SECTION("non-empty object with double") SECTION("non-empty object with double")
{ {
json const j = json const j =
@@ -499,6 +590,29 @@ TEST_CASE("BSON")
CHECK(json::from_bson(result, true, false) == j); CHECK(json::from_bson(result, true, false) == j);
} }
SECTION("array elements with non-conforming keys (lenient parsing)")
{
// documented lenient behavior (see gh-5333): BSON array element
// keys are not checked against the required decimal sequence
// "0", "1", "2", ... - elements are taken in encoded order
std::vector<std::uint8_t> const input =
{
0x26, 0x00, 0x00, 0x00, // size (little endian)
0x04, 'e', 'n', 't', 'r', 'y', '\x00', // entry: embedded array
0x1A, 0x00, 0x00, 0x00, // size (little endian)
0x10, '5', 0x00, 0x0A, 0x00, 0x00, 0x00, // key "5" (bogus) -> 10
0x10, 'x', 0x00, 0x14, 0x00, 0x00, 0x00, // key "x" (non-numeric) -> 20
0x10, '1', 0x00, 0x1E, 0x00, 0x00, 0x00, // key "1" (out of order) -> 30
0x00, // end marker (embedded array)
0x00 // end marker
};
const json expected = { { "entry", json::array({10, 20, 30}) } };
CHECK(json::from_bson(input) == expected);
}
SECTION("non-empty object with binary member") SECTION("non-empty object with binary member")
{ {
const size_t N = 10; const size_t N = 10;
@@ -594,6 +708,31 @@ TEST_CASE("BSON")
CHECK(json::from_bson(result, true, false) == j); CHECK(json::from_bson(result, true, false) == j);
} }
SECTION("binary member with subtype 0x02 (old binary) keeps its inner length prefix (lenient parsing)")
{
// documented lenient behavior (see gh-5333): the payload for
// binary subtype 0x02 ("old binary") is returned as-is,
// including its own inner 4-byte length prefix; it is not
// stripped or reinterpreted
std::vector<std::uint8_t> const input =
{
0x17, 0x00, 0x00, 0x00, // size (little endian)
0x05, 'e', 'n', 't', 'r', 'y', '\x00', // entry: binary
0x06, 0x00, 0x00, 0x00, // size of binary (little endian)
0x02, // "old binary" subtype
0x02, 0x00, 0x00, 0x00, // inner length prefix (part of the old-binary payload)
0x68, 0x69, // payload ('h', 'i')
0x00 // end marker
};
// the inner length prefix is part of the (unmodified) payload
const std::vector<std::uint8_t> expected_payload = {0x02, 0x00, 0x00, 0x00, 0x68, 0x69};
const json expected = { { "entry", json::binary(expected_payload, 0x02) } };
CHECK(json::from_bson(input) == expected);
}
SECTION("Some more complex document") SECTION("Some more complex document")
{ {
json const j = json const j =
-107
View File
@@ -14,40 +14,6 @@ using nlohmann::json;
#include <array> #include <array>
#include <sstream> #include <sstream>
#include <iomanip> #include <iomanip>
#include <cstdlib>
#include <new>
namespace
{
// heap allocation counter used by the regression test for issue #5413
// (https://github.com/nlohmann/json/issues/5413); disabled (and thus a
// no-op besides the counting) unless explicitly toggled on
bool count_heap_allocations = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
std::size_t heap_allocations = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
} // namespace
void* operator new (std::size_t size) // NOLINT(cppcoreguidelines-owning-memory,misc-new-delete-overloads)
{
if (count_heap_allocations)
{
++heap_allocations;
}
if (void* ptr = std::malloc(size)) // NOLINT(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory)
{
return ptr;
}
throw std::bad_alloc(); // NOLINT(hicpp-exception-baseclass)
}
void operator delete (void* ptr) noexcept // NOLINT(cppcoreguidelines-owning-memory,misc-new-delete-overloads)
{
std::free(ptr); // NOLINT(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory)
}
void operator delete (void* ptr, std::size_t /*size*/) noexcept // NOLINT(cppcoreguidelines-owning-memory,misc-new-delete-overloads)
{
std::free(ptr); // NOLINT(cppcoreguidelines-no-malloc,cppcoreguidelines-owning-memory)
}
TEST_CASE("serialization") TEST_CASE("serialization")
{ {
@@ -416,76 +382,3 @@ TEST_CASE("dump for basic_json with long double number_float_t")
check_same(100.0L, 100.0); check_same(100.0L, 100.0);
} }
} }
TEST_CASE("regression test for issue #5413 - lazily allocated indent_string")
{
// the serializer used to unconditionally allocate a 512-byte
// indent_string in its constructor, even though it is only ever read
// inside the pretty_print branches of dump(). This wasted a heap
// allocation (and its matching deallocation) on every single compact
// (i.e. non-pretty, the default) dump() call. indent_string is now
// allocated lazily, the first time a pretty-print branch actually
// needs it -- so a compact dump() must perform strictly fewer heap
// allocations than a pretty dump() of the same value.
const json j = {{"level", "info"}, {"msg", "hello world"}, {"id", 12345}};
// warm up anything unrelated to indentation (e.g., one-time locale
// lookups) that might otherwise allocate on first use regardless of
// pretty-printing, so it does not skew the counts measured below
const auto warmup = j.dump();
const auto warmup_pretty = j.dump(4);
CHECK(!warmup.empty());
CHECK(!warmup_pretty.empty());
SECTION("compact dump() has a stable, minimal allocation count")
{
count_heap_allocations = true;
heap_allocations = 0;
const auto compact1 = j.dump();
const auto allocs_compact1 = heap_allocations;
heap_allocations = 0;
const auto compact2 = j.dump(-1);
const auto allocs_compact2 = heap_allocations;
count_heap_allocations = false;
CHECK(compact1 == compact2);
// dump() and dump(-1) both take the compact code path and must
// never touch indent_string, so they allocate identically often
CHECK(allocs_compact1 == allocs_compact2);
}
SECTION("first pretty dump() allocates more than a compact dump()")
{
// use a tiny value whose compact ({"a":1}, 7 bytes) and pretty
// ({"a": 1} with 1-space indent, 11 bytes) serializations both stay
// well inside every common std::string small-string-optimization
// buffer (>= 15 bytes on libstdc++/MSVC STL, >= 22 on libc++), so
// building the result string itself causes no heap allocation
// either way -- isolating indent_string as the only thing that can
// possibly account for a difference in allocation count
const json tiny = {{"a", 1}};
count_heap_allocations = true;
heap_allocations = 0;
const auto compact = tiny.dump();
const auto allocs_compact = heap_allocations;
heap_allocations = 0;
const auto pretty = tiny.dump(1);
const auto allocs_pretty = heap_allocations;
count_heap_allocations = false;
CHECK(compact == "{\"a\":1}");
CHECK(pretty == "{\n \"a\": 1\n}");
// a fresh serializer is created per dump() call; the pretty branch
// lazily allocates indent_string on its first use, so it must
// allocate at least once more than the compact branch, which never
// touches indent_string at all
CHECK(allocs_pretty > allocs_compact);
}
}