Repository navigation
Description
Summary
Three value algorithms recurse once per nesting level with no depth limit, so each crashes the process on a deeply nested value: json::diff(), basic_json::merge_patch() and json_pointer::flatten() (reached through basic_json::flatten()).
The text parser is iterative, so json::parse accepts input of any depth. Any of these three called on such a value is a crash, which matters wherever the value came from somewhere untrusted — JSON Patch and JSON Merge Patch are typically applied to exactly that.
unflatten() is not affected: it iterates the object and lets get_and_create walk each pointer, and it handles a 500,000-level pointer without trouble. I checked, so that nobody has to.
Reproduction
Against develop (734fd30), -O2, macOS/arm64 with the default 8 MB stack. All three crash at depth 100,000:
#include <nlohmann/json.hpp>
#include <string>
int main()
{
const std::size_t depth = 100000;
const auto deep = [&](const char* leaf)
{
return nlohmann::json::parse(std::string(depth, '[') + leaf + std::string(depth, ']'));
};
const nlohmann::json a = deep("0");
const nlohmann::json b = deep("1");
const auto patch = nlohmann::json::diff(a, b); // SIGSEGV
return static_cast<int>(patch.size());
}| call | result |
|---|---|
json::diff(a, b) |
SIGSEGV |
target.merge_patch(patch) |
SIGSEGV |
j.flatten() |
SIGSEGV |
j.unflatten() |
fine — iterative |
merge_patch is measured with nested objects, since it only descends into objects.
Root cause
diff—basic_json::diff()(json.hpp:5089) calls itself per element: json.hpp:5120 (arrays), :5168 (objects)merge_patch—basic_json::merge_patch()(json.hpp:5231) calls itself per member: json.hpp:5247flatten—json_pointer::flatten()(json_pointer.hpp:861) calls itself per element: json_pointer.hpp:879 (arrays), :898 (objects)
Suggested fix
The same shape used for the destructor in #1436 and for copying, serializing and comparing in #5389 / #5285 / #5390: descend a bounded number of levels, then finish the rest on an explicit stack.
flatten looks the most straightforward of the three, since it only accumulates into result and carries a prefix string down. diff returns a value built from each level's result, and merge_patch mutates in place, so both need a little more care about what the explicit stack has to carry.
Related
- Stack overflow in copy constructor and dump() on deeply nested json (destructor was fixed in #1436) #5387 — the same problem in the copy constructor and
dump() - Stack overflow in binary format parsers (CBOR/MessagePack/UBJSON/BSON) due to unbounded recursion #5104 — the same problem in the binary readers
- Prevent stackoverflow caused by recursive deconstruction #1436 — where the destructor was fixed this way
Written by Claude Code.
Activity
dexhunter commentedon Aug 22, 2026
Would you welcome a focused PR that makes only
flatten()iterative, with a 100,000-level regression test and a current-head benchmark? I would leavediff()andmerge_patch()out of scope so the change stays small and does not overlap the open copy/comparison work. I have not started an implementation.Merge deeply nested objects without recursing per nesting level
Diff deeply nested values without recursing per nesting level
Diff deeply nested values without recursing per nesting level
Merge deeply nested objects without recursing per nesting level
Diff deeply nested values without recursing per nesting level
Diff deeply nested values without recursing per nesting level
Merge deeply nested objects without recursing per nesting level (#5547)
Diff deeply nested values without recursing per nesting level
gregmarr commentedon Sep 28, 2026
@nlohmann Even with this fixed in some of the PRs, we still ended up with two limits. Can they be combined?
nlohmann commentedon Sep 29, 2026
@gregmarr Good catch, thanks. The plan was to switch whichever of #5389 and #5390 merged second over to the shared limit, and that step got lost.
#5637 fixes it:
basic_json::nesting_depth_limit()is gone, and the thread-local counter used by the copy constructor and the comparison operators now checks againstdetail::recursion_depth_limit()as well.Only the limit is shared, not the mechanism. Copying and comparing have fixed signatures and can't take a depth argument, so they still count depth in a thread-local byte, while everything else passes the depth explicitly. A
static_assertmakes sure the shared limit stays below 255 so the byte can't overflow.Written by Claude Code.
nlohmann commentedon Oct 9, 2026
Closing this with #5548 left
flatten()out:json_pointer::flatten()still recursed once per level, and the 100,000-level repro still crashed ondevelop. #5792 makes it iterative.Written by Claude Code.
Flatten deeply nested values without recursing per nesting level (#5792)