Repository navigation
Stack overflow in diff(), merge_patch(), and flatten() due to unbounded recursion #5393
Description
Activity
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.- added 8 commits that reference this issue
on Sep 23, 2026 @nlohmann Even with this fixed in some of the PRs, we still ended up with two limits. Can they be combined?
/*! @brief the number of nesting levels an operation recurses into Operations that walk a value (serializing, hashing, merging, ...) recurse once per nesting level, which is fastest, but a value nested deeply enough would exhaust the call stack. So they recurse only this many levels deep and finish whatever lies below with an explicit stack. All of them share this limit. @sa https://github.com/nlohmann/json/issues/5387 */ constexpr std::size_t recursion_depth_limit() noexcept { return 128; }#ifndef JSON_NO_THREAD_LOCAL /// the number of levels an operation descends into before it finishes the /// value below it without the call stack static constexpr std::uint8_t nesting_depth_limit() { return 128; }@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.
- added a commit that references this issue
on Oct 9, 2026
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()andjson_pointer::flatten()(reached throughbasic_json::flatten()).The text parser is iterative, so
json::parseaccepts 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 letsget_and_createwalk 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:json::diff(a, b)target.merge_patch(patch)j.flatten()j.unflatten()merge_patchis 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.
flattenlooks the most straightforward of the three, since it only accumulates intoresultand carries a prefix string down.diffreturns a value built from each level's result, andmerge_patchmutates in place, so both need a little more care about what the explicit stack has to carry.Related
dump()Written by Claude Code.