Fix stack overflow in binary format parsers (CBOR/MessagePack/UBJSON/BSON) - #5293
Fix stack overflow in binary format parsers (CBOR/MessagePack/UBJSON/BSON)#5293Gooh456 wants to merge 16 commits into
Conversation
🔴 Amalgamation check failed! 🔴The source code has not been amalgamated and/or formatted correctly. 📎 A ready-to-apply patch is attached to the failed workflow run as the git apply amalgamation.patchThis does not require installing astyle yourself. @Gooh456 Please read and follow the Contribution Guidelines. |
|
Pushed a fix for the amalgamation check: it's exactly the patch the "Check amalgamation" workflow generated as its artifact (pure astyle formatting in a couple of pre-existing regions unrelated to the actual change), applied as-is. The Windows/Ubuntu CI failures are a more substantive, real finding I want to flag rather than paper over. Root cause, from reading the recursion structure: This is exactly why local verification can miss this: I verified the fix against an optimized MinGW g++ build on Windows, where frames are small enough that even ~100k logical levels didn't overflow — a materially different stack-cost-per-frame than what upstream's Debug-mode Windows CI matrix hits. Good catch by your CI, basically. I don't currently have an MSVC/clang-cl Debug toolchain available to compile-and-verify a corrected |
|
Follow-up on the CI failure flagged above: confirmed it's a real stack overflow ( Pushed |
|
My last fix for the Debug crash was too aggressive - I picked 128 without checking whether it was actually high enough for real data, and it wasn't. It broke the library's own roundtrip fixture (sample.json, 458 levels deep), which is why CI just lit up across basically every job instead of just the Debug ones. Pushed a fix: 600 instead of 128, which covers that fixture with real margin and stays well clear of the 1024 that caused the original overflow. Downloaded the actual fixture this time and measured its real depth instead of guessing (458), so this number's grounded in something concrete rather than a second guess. Sorry for the noise on this one - should've checked what depth real input needs before picking a number the first time around. |
…BSON) The binary format parsers in binary_reader.hpp parse nested arrays/objects using unbounded recursive descent, unlike the text JSON parser which is iterative. A deeply nested (but otherwise tiny) CBOR, MessagePack, UBJSON, or BSON document therefore exhausts the native call stack and crashes the process, rather than failing gracefully. Add a depth_guard RAII helper that tracks container-nesting depth on the binary_reader and rejects input nested deeper than a fixed max_depth (1024) with a clean parse_error (new id 116) instead of recursing further. The guard is placed at each recursive entry point: parse_cbor_internal, parse_msgpack_internal, parse_bson_internal (which also covers nested BSON sub-documents via parse_bson_element_internal), and get_ubjson_value (the actual recursive entry point for UBJSON/BJData, including the "optimized homogeneous array" code path that recurses without going through parse_ubjson_internal). Closes nlohmann#5104. Testing: - Reproduced the crash for real against unpatched code on Windows: a 100k-deep CBOR/MessagePack/UBJSON/BSON payload each terminate the process with STATUS_STACK_OVERFLOW (exit code 0xC00000FD), matching the reported Segmentation fault. - Rebuilt against the patched header: all four formats now throw a clean [json.exception.parse_error.116] instead of crashing; process exits 0. - Added regression tests (unit-cbor.cpp, unit-msgpack.cpp, unit-ubjson.cpp, unit-bson.cpp) reproducing deep nesting for each format and asserting the clean parse_error; ran them against the fix (pass) and confirmed they return the crash on unpatched code first. - Compiled and ran the full unit-cbor/unit-msgpack/unit-ubjson/unit-bson test binaries (g++ 16, C++17) against both the patched and pristine baseline: identical pre-existing failure set (8 test cases that require the external json_test_data corpus, not present in this checkout) in both builds, and the patched build additionally passes the new tests with zero regressions elsewhere (3,549,340 assertions passed on the patched build vs 3,549,332 on baseline - exactly the added assertions). - Confirmed moderate/legitimate nesting (well under the new limit) and a normal round-trip still parse correctly, so the fix does not introduce false positives for realistic input. - Regenerated single_include/nlohmann/json.hpp via tools/amalgamate, and documented the new parse_error.116 in docs/mkdocs/docs/home/exceptions.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
single_include/nlohmann/json.hpp had drifted out of astyle-formatting sync in a few pre-existing regions unrelated to this PR's actual change (json_fwd.hpp block indentation, a couple of wrapped call sites); this is the exact patch the "Check amalgamation" GitHub Action generated and offered as a downloadable artifact. Applying it as-is, no manual edits. Signed-off-by: Kyue <sendocat456@gmail.com>
CI on this branch crashed with a real SIGSEGV (stack overflow) in test-bson_cpp11 under msvc (Debug, x64), msvc-arm64 (Debug), and msvc-vs2026 (Debug, x64) - the depth_guard added to fix nlohmann#5104 correctly caps recursion at max_depth=1024, but reaching depth 1024 itself already overflows a 1 MiB default thread stack in unoptimized/Debug builds, where each nesting level costs several real (non-inlined) call frames instead of one. No MSVC toolchain is available in this environment, so instead of guessing a safe constant, I only lowered it (1024 -> 128, a 4x margin under the depth that crashed) and am relying on CI's real Windows/MSVC runners - which reproduced the original bug - to confirm this closes it. Verified locally with g++ 16/C++17 (not MSVC): recompiled and ran the affected unit-bson/cbor/msgpack/ubjson test binaries after updating the four depth-limit regression tests' expected byte offsets (which shift with max_depth: CBOR/MessagePack 1024->128, UBJSON 1025->129, BSON 7168->896, cross-checked against actual thrown exception messages, not guessed). All four pass; the only failures are the same 9 pre-existing "external json_test_data corpus not present" cases already documented as a known baseline gap in this checkout - no new regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
My previous commit dropped max_depth to 128 to fix the MSVC Debug stack overflow from nlohmann#5104, but that number was picked without checking whether it was actually high enough for real input. It wasn't: the library's own roundtrip fixture (tests/data/json_testsuite/sample.json, used by all four binary format roundtrip tests) legitimately nests 458 levels deep, so 128 started rejecting valid, previously-working input - visible in CI as widespread new failures across nearly every job, not just the Debug builds this was meant to fix. 600 covers that fixture with real margin (about 30% above the 458 it actually needs) while staying well clear of the 1024 that overflowed the stack in the first place (about 40% below it). Verified locally (g++, no MSVC available): downloaded the actual sample.json fixture and measured its real max nesting depth directly (458) rather than guessing, recomputed the four depth-limit regression tests' expected byte offsets for the new limit the same way as before (cross-checked against actual thrown exception text), and reran the full binary-format test files - all four depth-guard tests pass, and the only other failures are the same pre-existing "external test corpus not present in this checkout" gaps as before, nothing newly broken. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
…flow CI (clang-cl-12 x64, Windows Debug) still crashed with a real SIGSEGV in test-ubjson_cpp11 on the nlohmann#5104 regression test even with max_depth=600 in place, while the equivalent CBOR/MessagePack/BSON depth-limit tests passed fine at the same max_depth. The depth_guard itself was applied correctly on every UBJSON recursive call site (verified by reading every recursive path in get_ubjson_array/get_ubjson_object and the optimized $type#count path) - the bug wasn't a missing or bypassed check, it was that reaching the *same* depth of 600 costs UBJSON more real stack than it costs the other formats. Root cause: CBOR/MessagePack fold "read the next value's dispatch" and "the depth_guard" into one function (parse_cbor_internal / parse_msgpack_internal), so their recursive chain is two stack frames per nesting level (that function + get_cbor_array/get_msgpack_array). UBJSON splits this into two functions instead of one: parse_ubjson_internal() (fetch the next byte) calling get_ubjson_value() (the depth_guard + the switch), because get_ubjson_value() also has to be callable directly with an already-known type marker from the optimized `$type#count` container path - a feature the other formats don't have. That leaves parse_ubjson_internal() as a genuine extra frame on top of get_ubjson_value() and get_ubjson_array()/get_ubjson_object(): three frames per nesting level instead of two. Measured with `g++ -O0 -fstack-usage` (proxy for MSVC Debug's similarly unoptimized frames): parse_ubjson_internal/get_ubjson_value/get_ubjson_array together cost ~1488 bytes per nesting level pre-fix vs CBOR's parse_cbor_internal/get_cbor_array at ~1088 bytes/level - about 37% more stack per level for the same max_depth, comfortably enough to explain overflowing the 4 MB stack (tests/CMakeLists.txt already sets /STACK:4000000 for these test binaries, see nlohmann#2955) at depth 600 in MSVC Debug specifically. Fix: mark parse_ubjson_internal() JSON_HEDLEY_ALWAYS_INLINE so it collapses into its caller in every build configuration, including unoptimized Debug builds where the compiler wouldn't otherwise bother inlining a function this trivial. Confirmed with objdump that this eliminates every `call` to parse_ubjson_internal in the compiled object (previously 2 real calls, now 0) - i.e. it's not just a hint, the frame is actually gone - without touching get_ubjson_value's depth_guard/max_depth logic at all, so the 128->600 max_depth tuning from the previous two commits (needed for the 458-level-deep legitimate roundtrip fixture) is untouched. Regenerated single_include/nlohmann/json.hpp for this one function only (re-running the full amalgamate script produced unrelated whitespace churn in this environment, so I hand-applied the equivalent change to keep the diff minimal and matching). Verified locally with g++ 16 (MinGW, Debug, no MSVC available in this environment): rebuilt test-ubjson_cpp11/test-cbor_cpp11/test-msgpack_cpp11/ test-bson_cpp11 against both the multi-header and the amalgamated single_include build, and all non-data-file-dependent test cases pass, including the nlohmann#5104 stack-overflow regression test (691k+ assertions) and the pre-existing legitimate-deep-input roundtrip test. The only failures are the already-documented external-test-corpus-not-downloaded gap (network access unavailable here) and one pre-existing, unrelated vector::reserve failure in a CBOR RFC 7049 test that reproduces identically before and after this change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
9a12b01 to
2372e68
Compare
|
Pushed a fix for the On the stack overflow: I want to be clear this wasn't a missing or bypassed depth check. I went through every recursive call site in the UBJSON/BJData path ( The actual problem is that reaching the same depth of 600 costs UBJSON meaningfully more real stack than it costs the other three formats. CBOR and MessagePack fold "fetch+dispatch the next value" and the depth guard into a single function ( I measured this with Fix is just marking No MSVC available in my environment, so I can't reproduce the exact CI crash, but I rebuilt and ran test-ubjson/test-cbor/test-msgpack/test-bson locally (g++ 16, Debug, both the multi-header and the regenerated single_include build) and everything passes, including the #5104 regression test and the legitimate deep-nesting roundtrip test. Only failures are the pre-existing external-test-corpus-not-downloaded gap and one unrelated |
|
The more I think about it - maybe a non-recursive parser would be a better fix. WDYT? |
nlohmann suggested a non-recursive parser would be a better fix than continuing to tune max_depth against compiler/platform-specific stack frame sizes (the UBJSON force-inline fix still wasn't enough on clang-cl Debug). This does that: the container-parsing loops for CBOR, MessagePack, UBJSON/BJData, and BSON no longer recurse on the native call stack for nested arrays/objects. Each format's parse_*_internal() now runs a single iterative loop with an explicit, heap-allocated stack of small per-format "resume this container" frames (cbor_container_frame, msgpack_container_frame, ubjson_container_frame, bson_container_frame); descending into a nested array/object pushes a frame and loops back to parse its first child instead of recursing, and finishing a container pops its frame and resumes the parent. Scalar values (numbers, strings, binaries) are unaffected - they never recursed into container parsing in the first place. This makes native stack depth O(1) regardless of input nesting depth, so it eliminates the whole bug class (nlohmann#5104) rather than tuning a magic number against a specific compiler's frame sizes: CBOR's separate tag-unwrapping recursion is now a plain loop too (it was also uncapped in all but name, since it recursed through the same depth-guarded function). depth_guard and the per-call recursion-depth counter are gone entirely - they'd have nothing left to guard - and max_depth (now 100000, up from 600) is kept purely as a sanity/DoS cap against absurd/malicious inputs, not a stack-safety mechanism; it's checked against the explicit stack's size at container-entry time instead. While rewriting BSON's container handling I noticed nested *arrays* (record type 0x04) never went through the old depth_guard at all - only nested objects (type 0x03) did, via parse_bson_internal. The unified iterative loop applies the depth cap to both uniformly now, closing that gap; a regression test for it is included alongside the updated nlohmann#5104 tests. Testing: - Updated the four existing nlohmann#5104 regression tests (CBOR/MessagePack/ UBJSON/BSON) for the new max_depth=100000 and the resulting byte offsets in the parse_error messages; added a BSON array-nesting variant of the same test for the gap above. - Ran the full existing test suite (g++ 16, Debug, MinGW) - CBOR, MessagePack (cpp11+cpp17), UBJSON, BSON, BJData, and every other suite (94 binaries total) - and diffed assertion-by-assertion against a pristine baseline build of the pre-rewrite tree: every binary matches the baseline's pass/fail counts exactly except the two new BSON assertions, which pass. The one pre-existing failure per binary format suite (missing external json_test_data fuzz corpus - no network access in this environment - plus one unrelated vector::reserve failure in a CBOR RFC 7049 test) reproduces identically in both trees. - Added a throwaway smoke test parsing 500,000 levels of nesting (~830x the old max_depth of 600, far beyond anything the old recursive parser could have survived at any max_depth) for all four formats via a minimal non-DOM-building SAX consumer: all four reject it cleanly via parse_error.116 with no crash, confirming this isn't luck at one specific depth. Separately confirmed 50,000 levels of *legitimate* nesting (under the new cap, well past the old one) parses to completion correctly, and that the BJData "optimized homogeneous container" and ND-array-annotation paths - the trickiest part of the UBJSON rewrite - are exercised by the existing test suite and match baseline exactly. - Regenerated single_include/nlohmann/json.hpp via tools/amalgamate and reformatted with the pinned astyle (3.4.13) matching the CI "Check amalgamation" workflow's exact sequence (amalgamate.py for both json.hpp/json_fwd.hpp configs, then astyle on the amalgamated output and on include/tests/docs sources); diffed clean against a fresh regeneration. No real clang-cl/MSVC available in this environment, so the exact CI toolchain isn't locally reproducible - but the whole point of this rewrite is that stack safety no longer depends on compiler/platform frame sizes at all, so a clean run under a different compiler (optimized or not) is meaningful structural evidence rather than a coincidence of this one toolchain's frame sizes, the way the previous max_depth tuning attempts were. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Kyue <sendocat456@gmail.com>
|
Agreed, and I've pushed that now. I went back through the last few CI failures with that lens and yeah - every one of them was the same underlying problem wearing a different hat: max_depth was never actually a stack-safety mechanism, it was a proxy for one, and the proxy kept breaking because the real cost (native stack frames per nesting level) depends on the compiler, the optimization level, and incidental things like whether a helper function happens to get inlined. Tuning the number just moves where it breaks. So: the container-parsing loops for CBOR, MessagePack, UBJSON/BJData, and BSON are no longer recursive. Each format's parser now runs one iterative loop with an explicit, heap-allocated stack of small "resume this container" frames - descending into a nested array/object pushes a frame and loops back around to parse its first child instead of calling back into itself, and finishing a container pops the frame and resumes the parent. Scalars (numbers/strings/binary) don't recurse into container parsing at all, so they're untouched. One CBOR-specific thing worth calling out: the tag-unwrapping path ( While I was in there I also noticed BSON's depth guard only ever applied to nested objects (record type 0x03) - nested arrays (0x04) went through a separate path that never checked depth at all. The unified loop applies the cap to both now; added a regression test for that specifically since it wasn't caught by anything upstream. What I verified this against:
Honest caveat: I don't have real clang-cl or MSVC available in this environment, so I can't reproduce the exact CI toolchain locally - that's exactly the failure mode this rewrite is meant to stop mattering, but I haven't watched it pass on the actual Debug clang-cl/MSVC jobs with my own eyes yet. CI on this PR needs a maintainer approval to run (standard for external PRs, it looks like), so I'd treat this as "strong local evidence, not yet independently confirmed on the real matrix" until those jobs actually go green. |
…y_reader Signed-off-by: Kyue <sendocat456@gmail.com>
Signed-off-by: Kyue <sendocat456@gmail.com>
nlohmann
left a comment
There was a problem hiding this comment.
👋 Heads up: I'm on vacation, so this review was produced with the help of an AI assistant on my behalf and I haven't hand-verified every detail yet. Apologies for the impersonal reply — I wanted to get you actionable feedback rather than leave the PR sitting. I'll go over it properly when I'm back.
Thanks a lot for the iterative rewrite and for the very transparent write-ups on each CI round — this is the right direction.
One substantive concern: the DOM API can still overflow on tiny nested input
The parser itself is now genuinely non-recursive (nicely done — a 500k-deep payload is rejected cleanly for all four formats). But the crash appears to just move one frame later, into the return path of the from_* entry points:
return res ? result : basic_json(value_t::discarded);That ternary produces a prvalue, so result is copy-constructed, and basic_json's copy constructor is recursive. A validly-nested document below max_depth parses fine and then overflows the native stack on return.
Measured locally (Linux, 8 MB stack, -O2):
| Input | Result |
|---|---|
from_cbor, 40 000-deep arrays |
OK |
from_cbor, 60 000-deep arrays |
SIGSEGV — recursive basic_json copy ctor, on return |
json::parse, 200 000-deep text |
OK (returns result directly, no copy) |
So a ~60 KB CBOR file still crashes the process through from_cbor. On the Windows test binaries (linked /STACK:4000000) the threshold is roughly half that, and max_depth = 100000 sits comfortably above it — so the new guard never gets a chance to protect the DOM path.
Why current CI stays green: the regression tests here all exceed max_depth, so they throw/discard during parsing and the partial tree unwinds through the (iterative) destructor. The case that's still exposed is exactly the one #5104 is about: a valid, accepted, deeply-nested document that parses successfully and is returned.
Possible fixes:
- Return by move —
return res ? std::move(result) : basic_json(value_t::discarded);infrom_cbor/from_msgpack/from_ubjson/from_bson/from_bjdata. I tested this and it raised the safefrom_cbordepth from ~60k up to the fullmax_depthcap, matchingjson::parse. - And/or choose a
max_depththat stays below the library's safe recursion depth on the smallest supported (4 MB) stack, treating it as a genuine sanity cap rather than 100000.
Caveat worth noting: even with the move, a caller copying / comparing / dump-ing a 100000-deep returned value hits the same recursive copy constructor. That's a pre-existing, library-wide limitation (also reachable via json::parse), so not this PR's job to solve — but it's another reason to lean toward a more conservative cap.
What looks good
- Iterative parser verified: native stack is O(1); 500k-deep input rejected cleanly, no crash, all four formats.
- Correctness spot-checks against this branch's header all passed: indefinite CBOR arrays/maps, empty indefinite containers, mixed definite/indefinite nesting, chained tags in
ignoremode, half-float, UBJSON optimized$type#countarrays/objects, BJData ND-array (JData wrapping) and$Bbinary — plus ~20k random-structure round-trips across all six format variants (0 mismatches) and ~200k random-byte inputs (0 crashes / no non-jsonexceptions). - Closing the BSON nested-array (0x04) depth gap, with a dedicated test, is a real bonus.
Minor
- The clang 20.1.8 CI red is a MinGW
relocation truncated to fitlinker error inunit-regression2— a known large-object-file toolchain issue, unrelated to this diff.
Bottom line: great approach and the parser change is solid, but I'd hold off on merging it as "closes #5104" until the return-path copy overflow is handled — otherwise from_cbor and friends still crash on a small nested file.
Generated by Claude Code
|
Appreciate the thorough pass, and no worries about the AI-assisted disclaimer — the finding's real either way. The move fix is right, and it's an obvious miss on my end — On "the clang 20.1.8 red is unrelated to this diff" — I don't think that's quite right, and wanted to flag it before it gets waved off. Checked Pushed a shot at a CI-only mitigation: |
7026d20 to
7c92b95
Compare
Copying a deeply-nested basic_json on return is exactly as recursive as the stack overflow this PR is meant to fix, since basic_json's copy constructor recurses over the tree. Use std::move instead across all from_cbor/from_msgpack/from_ubjson/from_bjdata/from_bson overloads. Also add a CI-only workaround for the MinGW clang linker failing with "relocation truncated to fit" on test-regression2, which appears to be tipped over by this PR's added instantiated code (-fuse-ld=lld). Signed-off-by: Kyue <164024549+Gooh456@users.noreply.github.com>
7c92b95 to
a47902e
Compare
|
Please resolve the conflicts. |
…r-stack-overflow-5104 Signed-off-by: Kyue <164024549+Gooh456@users.noreply.github.com> # Conflicts: # include/nlohmann/detail/input/binary_reader.hpp # single_include/nlohmann/json.hpp # tests/src/unit-bson.cpp
|
done, merged develop in. conflicts were the bson size-check work you landed clashing with the iterative rewrite here, kept both: check_bson_document_size now runs per-frame off the stack instead of once at the end, so it validates nested docs/arrays too, not just the top-level one. dropped the old recursive parse_bson_element_internal/parse_bson_element_list/parse_bson_array helpers develop added, since they'd reintroduce the recursion this pr removes. tests from both sides kept as separate TEST_CASEs. ran the size-mismatch cases from your new tests plus a couple roundtrips locally against the merged header, all pass. |
🔴 Amalgamation check failed! 🔴The source code has not been amalgamated and/or formatted correctly. 📎 A ready-to-apply patch is attached to the failed workflow run as the git apply amalgamation.patchThis does not require installing astyle yourself. @Gooh456 Please read and follow the Contribution Guidelines. |
Signed-off-by: Kyue <164024549+Gooh456@users.noreply.github.com>
|
checked both failures, neither is this diff's fault.
Flawfinder flagged the two |
|
Please pull the latest develop branch where the CI is fixed. |
…r-stack-overflow-5104
|
Merged latest develop in (picks up the ubuntu-toolchain-r PPA retry fix + the two doc commits), no conflicts, no changes needed in include/single_include so amalgamation is still in sync. Pushed. |
nlohmann
left a comment
There was a problem hiding this comment.
The iterative approach is right and the execution is careful. Since the diff is too large to trust by eye, I verified it behaviourally rather than by reading alone.
How I checked it: built a recording SAX consumer that transcribes every event — including start_array/start_object size hints and exact parse_error text and byte offsets — compiled it against both develop's and this branch's single_include, and fed both identical bytes. Coverage: 400 random nested values × 7 encodings (including the to_ubjson/to_bjdata optimized-format variants and to_bson), each additionally truncated at 7 cut points to exercise EOF paths, plus ~200k marker-biased random byte strings across cbor/msgpack/ubjson/bjdata/bson in both strict and lax mode.
Result: byte-identical transcripts, ~790k lines, across 4 seeds. Also confirmed make amalgamate produces no diff. Together with the now fully-green matrix (all MSVC Debug configs, clang-cl-12, mingw x64, clang 20.1.8 — the ones that were red earlier), I'm reasonably confident this is behaviour-preserving. The BSON 0x04 depth gap it uncovered is a real pre-existing bug, and the std::move(result) catch is a real fix.
One substantive ask before merge: max_depth = 100000 doesn't buy what the docs claim. The parser is O(1) in native stack now, but dump(), copy-construction, to_cbor() and operator== are all still recursive — I measured from_cbor succeeding at depth 99,999 while dump() on that same value segfaults above ~12,000 on a 1 MB stack and ~86,000 on 8 MB. So json j = json::from_cbor(untrusted); j.dump(); still crashes, roughly 8x below the new limit on Windows. Details inline on the constant; either lower it to the low thousands (the deepest real fixture is 458 levels) or amend the exceptions.md wording.
Everything else inline is cleanup: SAX start events emitted before the depth check, nine copies of that check, a reference held across push_back in the BSON loop, a lost key.clear() capacity reuse, an unreachable branch with a misleading comment, the unrelated -fuse-ld=lld addition, and test duplication / a missing BJData case.
One process note: the PR description still describes the first implementation — depth_guard, max_depth = 1024, get_ubjson_value — none of which exist any more. Worth rewriting before merge so the commit message and future archaeology match the code.
Generated by Claude Code
| /// afford to be generous; it is far above the library's own deepest | ||
| /// legitimate test fixture (tests/data/json_testsuite/sample.json, 458 | ||
| /// levels). | ||
| static JSON_INLINE_VARIABLE constexpr std::size_t max_depth = 100000; |
There was a problem hiding this comment.
Please lower this. The parser is genuinely O(1) in native stack now, but dump(), the copy constructor, to_cbor() and operator== are all still recursive — so at 100000 the parser accepts values that nothing else in the library can touch.
Measured on this branch, parsing 99,999 nested CBOR arrays and then calling dump() on the result:
| stack | from_cbor |
dump() on the result |
|---|---|---|
| 8 MB (Linux default) | ok | SIGSEGV above ~86,000 |
| 1 MB (Windows default) | ok | SIGSEGV above ~12,000 |
So the obvious untrusted-input pattern still crashes, ~8x below the new limit on Windows:
json j = json::from_cbor(untrusted); // now succeeds
std::cout << j.dump(); // still segfaultsto_cbor() and copy-construction fail identically. (Destruction is fine — json_value::destroy is already iterative.)
I realise deep-but-parseable values are a pre-existing library-wide property — json::parse on text can already build undumpable values — and I agree that's out of scope here. But the choice of 100000 is what makes the binary readers newly able to produce them, where previously they'd have errored out.
Suggest something in the low thousands: the deepest legitimate fixture in the repo is tests/data/json_testsuite/sample.json at 458 levels, so e.g. 4096 keeps ~9x headroom over real input while keeping successfully-parsed values dumpable on a 1 MB stack. It would also make the four new regression tests a good deal cheaper (each currently builds a 100k-deep payload, and the BSON pair allocate ~800 KB buffers plus a 100k-entry int32_t table).
Generated by Claude Code
| (100000). The binary format parsers track container nesting on an explicit, | ||
| heap-allocated stack rather than the native call stack, so nesting depth can | ||
| no longer cause a stack overflow regardless of this limit; the limit exists | ||
| purely as a sanity check against absurd/malicious inputs (for example, one | ||
| designed to exhaust memory or time rather than the call stack), and inputs | ||
| nested beyond it are rejected with this parse error instead. |
There was a problem hiding this comment.
"nesting depth can no longer cause a stack overflow regardless of this limit" is true of the parser, but not of anything a caller does with the returned value — dump(), copy-construction, to_cbor() and operator== are all still recursive, and segfault well below 100000 (see my comment on max_depth).
If max_depth comes down to a few thousand this paragraph is fine as written. If you'd rather keep 100000, this needs a caveat that operations on the parsed value remain depth-limited, otherwise it reads as a guarantee the library doesn't provide.
Generated by Claude Code
| const bool started = is_object ? sax->start_object(len) : sax->start_array(len); | ||
| if (JSON_HEDLEY_UNLIKELY(!started)) | ||
| { | ||
| binary_t b; | ||
| return get_cbor_binary(b) && sax->binary(b); | ||
| return 0; | ||
| } | ||
|
|
||
| // UTF-8 string (0x00..0x17 bytes follow) | ||
| case 0x60: | ||
| case 0x61: | ||
| case 0x62: | ||
| case 0x63: | ||
| case 0x64: | ||
| case 0x65: | ||
| case 0x66: | ||
| case 0x67: | ||
| case 0x68: | ||
| case 0x69: | ||
| case 0x6A: | ||
| case 0x6B: | ||
| case 0x6C: | ||
| case 0x6D: | ||
| case 0x6E: | ||
| case 0x6F: | ||
| case 0x70: | ||
| case 0x71: | ||
| case 0x72: | ||
| case 0x73: | ||
| case 0x74: | ||
| case 0x75: | ||
| case 0x76: | ||
| case 0x77: | ||
| case 0x78: // UTF-8 string (one-byte uint8_t for n follows) | ||
| case 0x79: // UTF-8 string (two-byte uint16_t for n follow) | ||
| case 0x7A: // UTF-8 string (four-byte uint32_t for n follow) | ||
| case 0x7B: // UTF-8 string (eight-byte uint64_t for n follow) | ||
| case 0x7F: // UTF-8 string (indefinite length) | ||
| if (len == 0) | ||
| { | ||
| string_t s; | ||
| return get_cbor_string(s) && sax->string(s); | ||
| } | ||
|
|
||
| // array (0x00..0x17 data items follow) | ||
| case 0x80: | ||
| case 0x81: | ||
| case 0x82: | ||
| case 0x83: | ||
| case 0x84: | ||
| case 0x85: | ||
| case 0x86: | ||
| case 0x87: | ||
| case 0x88: | ||
| case 0x89: | ||
| case 0x8A: | ||
| case 0x8B: | ||
| case 0x8C: | ||
| case 0x8D: | ||
| case 0x8E: | ||
| case 0x8F: | ||
| case 0x90: | ||
| case 0x91: | ||
| case 0x92: | ||
| case 0x93: | ||
| case 0x94: | ||
| case 0x95: | ||
| case 0x96: | ||
| case 0x97: | ||
| return get_cbor_array( | ||
| conditional_static_cast<std::size_t>(static_cast<unsigned int>(current) & 0x1Fu), tag_handler); | ||
|
|
||
| case 0x98: // array (one-byte uint8_t for n follows) | ||
| { | ||
| std::uint8_t len{}; | ||
| return get_number(input_format_t::cbor, len) && get_cbor_array(static_cast<std::size_t>(len), tag_handler); | ||
| const bool ended = is_object ? sax->end_object() : sax->end_array(); | ||
| return ended ? 2 : 0; | ||
| } | ||
|
|
||
| case 0x99: // array (two-byte uint16_t for n follow) | ||
| if (JSON_HEDLEY_UNLIKELY(stack.size() >= max_depth)) | ||
| { | ||
| std::uint16_t len{}; | ||
| return get_number(input_format_t::cbor, len) && get_cbor_array(static_cast<std::size_t>(len), tag_handler); | ||
| sax->parse_error(chars_read, get_token_string(), | ||
| parse_error::create(116, chars_read, | ||
| exception_message(input_format_t::cbor, "maximum depth of nested arrays/objects exceeded", "value"), nullptr)); | ||
| return 0; | ||
| } |
There was a problem hiding this comment.
Two things about this block, both of which apply to all nine copies of it (grep -c "maximum depth of nested" → 9).
1. The depth check fires after the SAX start event. sax->start_array()/start_object() is already emitted at line 577 by the time we reject, so a custom SAX consumer sees an unmatched start immediately before parse_error. json_sax_dom_parser doesn't care, but it's a contract wart for third-party consumers. It's the same in UBJSON (2204, 2234, 2253, 2290, 2308) and worse in BSON, where begin_document has also already consumed the 4-byte size prefix. Hoisting the check above the start event is nearly free.
2. Nine near-identical copies. A small helper would collapse these:
bool reached_max_depth(const input_format_t format)
{
return sax->parse_error(chars_read, get_token_string(),
parse_error::create(116, chars_read,
exception_message(format, "maximum depth of nested arrays/objects exceeded", "value"), nullptr));
}Beyond the repetition, it makes it much harder to add a new container entry point that forgets the check — which is exactly the bug this PR found in BSON's 0x04 path.
Generated by Claude Code
| { | ||
| return false; | ||
| } | ||
| const bson_container_frame& top = stack.back(); |
There was a problem hiding this comment.
This reference is held across stack.push_back() at lines 319 and 337, either of which can reallocate the vector and invalidate it.
It's safe as written — both pushes are immediately followed by continue, so top is never read after the push — but that's a fragile invariant to leave implicit in the parser this PR exists to harden. One inserted statement below either push_back turns it into a use-after-free.
Suggest either re-reading stack.back() after the switch, or tracking an index instead of a reference.
Generated by Claude Code
| { | ||
| return sax->parse_error(chars_read, get_token_string(), | ||
| parse_error::create(116, chars_read, | ||
| exception_message(input_format_t::bson, "maximum depth of nested objects/arrays exceeded", "value"), nullptr)); |
There was a problem hiding this comment.
Word-order nit, but it's in a user-visible exception message: BSON says "maximum depth of nested objects/arrays exceeded" (here and at line 335), while CBOR, MessagePack and UBJSON all say "...arrays/objects...". exceptions.md documents the latter. Worth making all five identical so the string is greppable.
Generated by Claude Code
| { | ||
| get(); | ||
| } | ||
| string_t key; |
There was a problem hiding this comment.
Small perf regression: the old get_cbor_object/get_msgpack_object/get_ubjson_object/parse_bson_element_list all hoisted string_t key; outside the element loop and called key.clear() at the end of each iteration, specifically to reuse the string's capacity. Constructing it inside the loop gives up that reuse — one extra allocation per object key on object-heavy payloads.
Same in the other three loops (272 for BSON, 1464 for MessagePack, 2354 for UBJSON). Hoisting it above the while (true) and clearing after each key would restore the old behaviour; probably worth a benchmark run either way since object parsing is a hot path.
Generated by Claude Code
| // else: object frame with a key already read; fetch_char is | ||
| // already set to true from when the key was read above. |
There was a problem hiding this comment.
This state is unreachable, so the comment describes an invariant that doesn't exist.
An object frame always has awaiting_key == true at the top of the loop: it's initialised true in enter_container, produce() sets it back to true after every value, and the key-read path at 643 clears it but then falls through to read_value in the same iteration without a continue. So top.is_object && !top.awaiting_key never holds here.
No bug today, but the comment implies fetch_char can arrive here carrying a value from a previous iteration, which invites someone to reason about a stale-fetch_char case that can't happen. Suggest dropping the branch comment or rewording it to say the state is unreachable. Same at 1487 (MessagePack) and 2383 (UBJSON).
Generated by Claude Code
| if (CMAKE_CXX_COMPILER_ID STREQUAL "Clang" AND MINGW) | ||
| # test-regression2 is already the largest test TU and exercises CBOR/BSON/ | ||
| # MessagePack/UBJSON parsing; GNU ld's COFF backend can exhaust addressable | ||
| # range for IMAGE_REL_AMD64_REL32 relocations against .rdata once template | ||
| # instantiation grows far enough ("relocation truncated to fit"). lld lays | ||
| # out COFF sections differently and doesn't hit this ceiling for the same input. | ||
| json_test_set_test_options(test-regression2 LINK_OPTIONS -fuse-ld=lld) | ||
| endif() |
There was a problem hiding this comment.
This is unrelated build infrastructure that went in as an admitted untested hypothesis for the relocation truncated to fit failure. CI is green now, but the branch has merged develop twice since — worth confirming it's still load-bearing before it becomes permanent. If the link error is gone anyway, dropping it keeps the diff focused.
If it stays: as written it hard-requires lld to be installed for anyone building the tests with Clang+MinGW, with no availability check and no fallback. A find_program/check_linker_flag guard would make it degrade instead of breaking the build for people who don't have it.
Separately, and in the other direction — with the parsers no longer recursive, the /STACK:4000000 workaround for #2955 just above (line 104) may now be obsolete for test-cbor;test-msgpack;test-ubjson;test-bjdata;test-binary_formats. Removing it would be a nice demonstration that the rewrite actually did its job, though the tests do also build deep DOMs and dump() them, so it might still be needed — worth a check.
Generated by Claude Code
| CHECK(!json::sax_parse(bson, &scp, json::input_format_t::bson)); | ||
| } | ||
|
|
||
| TEST_CASE("BSON regressions") |
There was a problem hiding this comment.
Good catch on the array case — the 0x04 path genuinely never had a depth check, and it deserves the dedicated test.
Two things on the shape of it:
Duplication. The two sections are ~50 lines of near-identical payload construction differing only in the record type byte (0x03/0x04) and the key ('a'/'0'). A lambda taking those two parameters and returning the buffer would halve this and make the actual assertion the visible part of each section.
No BJData equivalent. unit-bjdata.cpp has no depth test. BJData shares the UBJSON code path so it is covered in practice, but the suite mirrors UBJSON coverage into unit-bjdata.cpp elsewhere, and BJData reaches enter_array through paths UBJSON doesn't (the ND-array _ArrayData_ frame, the optimized $type#count container). One case there would be cheap insurance.
Generated by Claude Code
… 4096 Nine near-identical depth-check blocks (2 BSON, 1 CBOR, 1 MessagePack, 5 UBJSON) collapse into one reached_max_depth(format) helper, which also fixes the BSON "objects/arrays" vs the other formats' "arrays/objects" wording mismatch by construction. Every depth check now runs before the corresponding SAX start_object()/start_array() event fires, instead of after - a custom SAX consumer no longer sees an unmatched start immediately before the parse_error. BSON's begin_document() is split into a byte-consuming read_document_header() and a separate SAX-emitting step so the depth check can sit between them; CBOR/MessagePack/UBJSON's enter_container()/ enter_array()/enter_object() are reordered the same way, using the fact that the "did we actually push a frame" condition (len != 0, or current != the container's close marker for indefinite/BSON-terminated forms) is already known before the SAX call. max_depth drops from 100000 to 4096: the iterative parser itself is O(1) in native stack regardless of this value, but dump(), the copy constructor, to_cbor()/to_msgpack()/to_ubjson()/to_bson(), and operator== on the parsed result are all still recursive and can overflow well below 100000 (measured: dump() around ~12000 on a 1 MiB stack). 4096 keeps ~9x headroom over the deepest real fixture in the repo (458 levels) while staying safe for those operations too. Also: the BSON parser's `top` reference into the frame stack no longer outlives a stack.push_back() that could reallocate it (was safe today only because a continue always followed each push, which is a fragile invariant to leave implicit); key strings are hoisted outside their element loops again in all four formats and cleared per-iteration instead of being reconstructed, restoring the capacity reuse the iterative rewrite had dropped; and three comments describing an unreachable "is_object && !awaiting_key" branch state are reworded to say so instead of implying a stale-fetch_char case that can't happen.
…Data Resize the five stack-overflow regression tests from n=100010 (sized for the old max_depth=100000) down to n=4200, comfortably past the new 4096 limit; updated the expected byte offsets and exception text (now "arrays/objects" everywhere, matching the shared helper) to match. The smaller payloads also make these tests noticeably cheaper to build and run. unit-bson.cpp: extracted the object-depth and array-depth sections' ~50 lines of near-identical payload construction into one make_deeply_nested_bson(n, record_type, key_char) lambda shared by both. unit-bjdata.cpp: added a "BJData regressions" test case with the same plain-nested-array depth test as UBJSON, run under the bjdata format tag specifically - BJData reaches enter_array() through paths UBJSON doesn't have (the ND-array _ArrayData_ frame, the optimized $type#count container), so it had no dedicated depth coverage of its own before this.
The Clang+MinGW -fuse-ld=lld addition for test-regression2's linker error was an untested hypothesis when it went in, with no availability check - it would break the build for anyone building the tests with Clang+MinGW who doesn't happen to have lld installed. find_program degrades instead of failing when it's missing. Left as a guarded opt-in rather than removed outright: I don't have a Clang+MinGW toolchain in this environment to confirm the original linker error is actually gone now that develop has been merged in twice since.
…caveat Reflects the lowered limit and its example byte offset, and adds the caveat @nlohmann's review asked for: the O(1)-native-stack guarantee is specific to parsing, not to dump()/copy/to_cbor() and friends on the result.
|
Thanks for the thorough pass (and the AI-disclosure note - appreciated either way, the findings hold up). Pushed fixes for everything in this round:
Also resized the five depth-limit regression tests from n=100010 down to n=4200 (still comfortably past the new limit) since they were sized for the old max_depth and don't need to be that big anymore - cheaper to build and run. No MSVC/clang-cl available in my environment (same gap as every round of this PR so far), so I can't independently confirm the toolchain-specific stuff beyond what I could verify with g++/MinGW: full |
|
Please check the DCO requirements and sign off your commits. |
What
Closes #5104.
The binary format parsers in
binary_reader.hpp(CBOR, MessagePack, UBJSON/BJData, BSON) used to parse nested arrays/objects/documents with unbounded native recursion. A deeply nested but otherwise tiny payload could exhaust the call stack and crash the process instead of failing gracefully - a DoS vector for anything parsing untrusted binary input.Each format's container-parsing loop is now iterative: descending into a nested array/object/document pushes a small "resume this container" frame onto a heap-allocated stack and loops back around to parse its first child, instead of recursing on the native call stack. Finishing a container pops its frame and resumes the parent. Native call-stack depth used while parsing is therefore O(1) regardless of how deeply the input nests - verified with a 500,000-level-deep smoke input across all four formats, cleanly rejected, no crash.
max_depth(4096) is a leftover sanity/DoS cap now rather than a stack-safety mechanism for parsing itself - but it's still needed, becausedump(), the copy constructor,to_cbor()/to_msgpack()/to_ubjson()/to_bson(), andoperator==on the result of parsing are all still recursive. A successfully parsed value nested deeper than the low thousands can overflow the native stack in those regardless of how it was produced, so the limit stays conservative (458 levels is the deepest real fixture in the repo) rather than being raised as high as the O(1) parser alone would allow.A few correctness fixes fell out of getting here, mostly from @nlohmann's review:
from_cbor/from_msgpack/from_ubjson/from_bson/from_bjdatareturn their result bystd::moveinstead of copy - the copy constructor is recursive, so returning by value was itself a stack-overflow path on the return side that completely bypassedmax_depth.reached_max_depth()helper, and every check now runs before its corresponding SAXstart_object()/start_array()event fires rather than after (a rejected container's start event was previously already emitted, which is a contract wart for custom SAX consumers).stack.push_back()that could reallocate it (safe today only because acontinuealways followed, a fragile invariant to leave implicit); key-string capacity reuse the iterative rewrite had accidentally dropped in all four formats; inconsistent "objects/arrays" vs "arrays/objects" wording between BSON and the other three formats; a couple of comments describing branch states that are actually unreachable.Testing
unit-cbor.cpp,unit-msgpack.cpp,unit-ubjson.cpp,unit-bjdata.cpp(new), andunit-bson.cpp(object and array nesting), each building a payload nested pastmax_depthand asserting the cleanparse_error.116in both throwing and non-throwing (is_discarded()) modes.unit-cbor/unit-msgpack/unit-ubjson/unit-bson/unit-bjdata/unit-binary_formatstest binaries (g++ 16, C++17, against the realjson_test_datacorpus) - all pass, zero failures, ~4.2M assertions total across the five main suites.$type#countarrays/objects, BJData ND-array (JData wrapping) and$Bbinary, and normal round-trips for all formats.single_include/nlohmann/json.hppand reformatted with the pinned astyle version (3.4.13), matching the amalgamation-check workflow's exact steps; re-running both amalgamate and astyle afterward produces zero further diff.Known gaps
tests/CMakeLists.txt's/STACK:4000000MSVC override (from Test #9: test-cbor test case sample.json fails in debug mode - Stack overflow #2955) untouched. Loweringmax_depthmay have made it unnecessary for the parsing path specifically, but the same test binaries also build anddump()values near the depth limit, and I have no way to confirm on real MSVC that removing it is safe - flagging rather than guessing.unit-bjdata.cpp's new depth test covers the plain nested-array path (shared with UBJSON) under thebjdataformat tag; I didn't attempt a depth test specifically through the ND-array/optimized-type path, since hand-constructing a valid payload for that is more involved and the underlyingenter_array()check is the same code either way.Originally implemented with the help of an AI assistant; iterated across review rounds with the same.