From 8dd9bd5300bb18052fa74560d92a3694c4728e63 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 05:12:56 +0200 Subject: [PATCH] Research: payload nesting-depth limits across serialization libraries Prior art for cultlib-gaps:question:payload-depth-limit: defaults, what each library counts, read/write enforcement, and the stack-exhaustion advisories behind them. Co-Authored-By: Claude Opus 5.5 --- docs/research/payload-depth-limit.md | 221 +++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/research/payload-depth-limit.md diff --git a/docs/research/payload-depth-limit.md b/docs/research/payload-depth-limit.md new file mode 100644 index 00000000..44ef6e26 --- /dev/null +++ b/docs/research/payload-depth-limit.md @@ -0,0 +1,221 @@ +# Payload nesting limits in comparable libraries + +Fork: `cultlib-gaps:question:payload-depth-limit` (options reference-500, shared-256, shared-128). +Raised in `cultlib-gaps:cut_spec:cut-union-bounds-cs.r1`. Gathered 2026-10-11 by Eyes +(`eyes-depth-limit-prior-art`). Facts only; no recommendation. Source files were fetched +from each project's default branch on 2026-10-11 (raw.githubusercontent.com); line numbers +refer to those fetches and move with the branch. "(inferred)" marks a reading of code or +text that the source does not state. + +Terms: "read" = decode/parse; "write" = encode/serialize; "skipped" = content a decoder +walks past without materialising (ignored fields, unknown fields). + +## 1. Summary table + +| Library | Default | Counts | Read / write | Source | +|---|---|---|---|---| +| MessagePack-CSharp (UntrustedData and TrustedData) | 500 (`MaximumObjectGraphDepth`) | each `DepthStep` call: formatter-driven, one step per container a formatter enters; check is `Depth >= max` before increment | read only; skipped content not counted unless a formatter calls DepthStep (see 2.1) | MessagePackSecurity.cs:31,41,92,266-273 | +| rmp-serde | 1024 (`depth: u16`, `set_max_depth`) | containers (array, map, ext newtype) via `depth_count!`; counter decremented, error at 0 | read: yes; write: error variant exists but is never constructed | decode.rs:51-60,213,294,309-310,566,583,616; encode.rs:37,49,64 | +| serde_json | 128 (`remaining_depth`, `disable_recursion_limit`) | each `[` and `{` entered by `deserialize_any`-style paths; error when the counter reaches 0 (deepest enterable container is 127, inferred from the decrement-then-test-zero code) | read only; serializer has no depth code; skipped values (`ignore_value`) are iterative and contain no `check_recursion!` call | de.rs:63,1357-1387,1434,1445,1102-1215; ser.rs (no match) | +| msgpack-python (C unpacker) | 1024 (`MSGPACK_EMBED_STACK_SIZE`) | each container pushed on a fixed stack, before the zero-length check; raises `StackError` | read: yes (including `skip()`); write: `Packer` `DEFAULT_RECURSE_LIMIT = 1024` (`ValueError`) | unpack.h:19; unpack_template.h:117-127; _unpacker.pyx:208-211,524; _packer.pyx:42,259-262 | +| msgpack-python (pure-Python fallback) | Python's recursion limit; `RecursionError` is mapped to `StackError` | recursion frames | read; write via `DEFAULT_RECURSE_LIMIT = 1024` | fallback.py:66,586-606 | +| @msgpack/msgpack | decode: no depth limit found (heap-allocated `StackPool`, iterative); encode `maxDepth` 100 | encode: depth argument to `doEncode` | read: unbounded; write: yes (`Too deep objects in depth N`) | Decoder.ts:111-190,640-643; Encoder.ts:9,97,173; README.md:119 | +| msgpackr | no depth option found | recursive `readObject` (inferred from the single recursion comment at unpack.js:506) | neither | unpack.js, pack.js (grep for depth/maxDepth: no hit) | +| System.Text.Json | 64 (reader and serializer); writer default 1000 | `JsonReaderOptions.MaxDepth`; `JsonSerializerOptions.MaxDepth` default is the minimum of reader and writer defaults | read: yes; write: yes (writer 1000, serializer 64) | JsonReaderOptions.cs:13,43; JsonWriterOptions.cs:18; JsonSerializerOptions.cs:27-28,580-593 | +| Newtonsoft.Json | 64 (`JsonReader` ctor; `MaxDepth` nullable) | reader `Depth + 1 > _maxDepth` | read; `JsonWriter.cs` has no MaxDepth match | JsonReader.cs:332,356-359 | +| protobuf C++ | 100 (`default_recursion_limit_`) | recursion budget decremented per nested message/group | read | coded_stream.h:630,1588-1602,1637 | +| protobuf C# | 100 (`DefaultRecursionLimit`) | CodedInputStream recursion | read | CodedInputStream.cs:57,120-147 | +| protobuf Java | 100 (`defaultRecursionLimit`) | CodedInputStream recursion | read | CodedInputStream.java:45,52,521 | +| protobuf upb | 100 (`kUpb_WireFormat_DefaultDepthLimit`) | `d->depth` per decode; max configurable up to a uint16 | read | upb/wire/internal/constants.h:11; decode.c:105,140,192,619,1306-1312 | +| ciborium | 256 (`recurse` counter) | `recurse()` wrapper around array/map/tag decode | read only (cib_ser.rs has no depth/recurs match) | ciborium/src/de/mod.rs:54-74,812,854 | +| cbor2 (Python) | 400 `max_depth` since 5.9.0 (CVE-2026-26209 fix); 6.0.0 (2026-04-28) rewrote the decoder in Rust as iterative, "limited only by the available memory" | containers | read | docs/versionhistory.rst:120-142,172-173 | +| BSON (MongoDB) | 100 levels | "Each object or array adds a level" | the doc page does not say read vs write | mongodb.com/docs/manual/reference/limits/ | +| Go encoding/json | 10000 (`maxNestingDepth`) | scanner `parseState` stack length; the scanner validates the whole input before decoding (`checkValid`) | read; encoder has no depth limit, only pointer-cycle detection after 1000 pointer levels (`startDetectingCyclesAfter`) | scanner.go:146-186; encode.go:298-305,764 | +| simdjson | 1024 (`DEFAULT_MAX_DEPTH`) | parser allocation parameter | read | common_defs.h:49; dom/parser.h:596,599 | + +## 2. Detail by library + +### 2.1 MessagePack-CSharp (the C# reference's decoder) + +- `UntrustedData` and `TrustedData` both set `MaximumObjectGraphDepth = 500` + (MessagePackSecurity.cs:31, 41; property initialiser 92). `TrustedData` was lowered to 500 + by commit 6b025eab "Limit stack depth to 500 even for trusted data" (2024-10-29) and + set from `UntrustedData` by 7c087905 (2024-11-28), per the commit history of + MessagePackSecurity.cs (api.github.com/repos/MessagePack-CSharp/MessagePack-CSharp/commits?path=src/MessagePack/MessagePackSecurity.cs). + The commit messages give no rationale for the value 500. +- `DepthStep(ref MessagePackReader)` throws `InsufficientExecutionStackException` when + `reader.Depth >= MaximumObjectGraphDepth`, else increments (MessagePackSecurity.cs:266-273). + The doc comment says callers must decrement after leaving the edge (:254-260). +- Counting is by contract with formatters, not by the reader: README.md says `DepthStep` and + `Depth--` "should surround the bulk of any `IMessagePackFormatter.Deserialize` method" + (README lines ~1161-1175). So content no formatter reads is not counted (inferred from the + README contract; the CultLib measurement in the question confirms it: an extra trailing slot + of 100000 nested arrays is accepted). +- Write: no depth check in the serializer or writer in the code fetched (MessagePackReader.cs + holds `Depth` at :53-75,133; the writer was not fetched). The CultLib question reports a + 3001-deep payload written and then refused on read. +- Advisory GHSA-7q36-4xx7-xcxf / CVE-2020-5234 (published 2020-01-31): "Untrusted data can lead + to DoS attack due to hash collisions and stack overflow in MessagePack". Affected < 1.9.11 and + >= 2.0.0 < 2.1.90. Mitigation is opting into `UntrustedData`; the advisory text does not state + the depth value. +- Advisory GHSA-cj9g-3mj2-g8vv / CVE-2026-48512 (published 2026-06-25), fixed in 2.5.301 and + 3.1.7: the JSON conversion helpers (`ConvertFromJson`, `TinyJsonReader.ReadNextToken`, the + ext-100 branch of `ConvertToJson`) recursed without consulting `MaximumObjectGraphDepth`; the + advisory says `UntrustedData` "does not fully mitigate" them. This is the class of defect + "a depth limit exists but one recursive path skips it". + +### 2.2 rmp-serde (the Rust runtime's decoder) + +- `Deserializer` has `depth: u16`, initialised to 1024 (decode.rs:181, 213, 294); + `set_max_depth` clamps to `u16::MAX` (:309-310). +- `depth_count!` decrements, returns `DepthLimitExceeded` when the counter reaches 0, runs the + body, increments (decode.rs:51-62). Used for arrays (:566), maps (:583) and ext newtype (:616). + So the usable nesting is 1023 levels (inferred from decrement-then-test-zero). +- `ignored_any` is forwarded to `deserialize_any` (decode.rs:~749-753 `forward_to_deserialize_any!`), + which goes through the same counted paths: skipped containers are counted. +- Write: `Error::DepthLimitExceeded` exists in encode.rs (:37, 49, 64) but a grep finds no site + that constructs it (only definition, `source`, `Display`). The writer has no depth check + (inferred from the three matches). +- Crate version at fetch: 1.3.1 (Cargo.toml). No RUSTSEC advisory about depth was found for + rmp-serde, rmpv or ciborium in a web search (search-engine result, not a database query). + +### 2.3 serde_json + +- `remaining_depth: u8 = 128` (de.rs:34, 63); `check_recursion!` decrements and errors with + `RecursionLimitExceeded` when it reaches 0 (de.rs:1372-1387), used at the `[`/`{` branches of + `deserialize_any` and the typed deserialize methods (de.rs:1434, 1445, 1750, 1801, 1838, 1849, + 1882). `disable_recursion_limit()` exists behind the `unbounded_depth` feature (de.rs:197-216, + 1357-1370). A third-party analysis (search result, not fetched) states the deepest enterable + container is level 127. +- `deserialize_ignored_any` calls `ignore_value()` (de.rs:1910-1916). `ignore_value` (:1102-~1215) + is an explicit loop with a heap `scratch` stack of frames and contains no `check_recursion!` + (the `check_recursion!` lines in the file are all at 1372+). Reading: ignored values in + serde_json are walked iteratively and are not depth-limited (inferred from the code; not + tested). This differs from the question's statement that "serde_json ... count ignored values + the same way". +- Serializer: grep of ser.rs for depth/recurs returns no match. +- Related: serde-json-wasm GHSA-rr69-rxr6-8qwf / RUSTSEC-2024-0012 (2024-02-09), fixed in 1.0.1 + and 0.5.2: "This was fixed by adding a check for recursion depth"; the advisory text gives no value. + rustc_serialize RUSTSEC-2022-0004: parsing had no stack-depth limit and "can cause a stack + overflow, which aborts the process". +- serde_stacker (crates.io description) grows the stack dynamically and is documented as paired + with `disable_recursion_limit()`; its README warns that Drop, Debug and Display on deep values + can still overflow. A forum thread reports dropping a deeply nested `Value` (~10 million + levels) overflowing after a successful parse. + +### 2.4 msgpack-python + +- C unpacker: `MSGPACK_EMBED_STACK_SIZE` 1024 (unpack.h:19); `start_container` returns -3 when + `top >= 1024` before it checks for an empty container (unpack_template.h:117-127), and -3 is + raised as `StackError` (_unpacker.pyx:208-211, 521-524). The docstring says "Raises `StackError` + when `packed` contains too nested" (_unpacker.pyx:158, 313). Empty containers at depth 1025 are + therefore also refused (inferred from check order). `skip()` runs the same state machine + (fallback.py:599-606 shows `skip` raising `StackError`; the C path is the same unpack function, + inferred). +- Pure-Python fallback: depth is Python's recursion limit; `RecursionError` is converted to + `StackError` (fallback.py:586-606). +- Packer: `DEFAULT_RECURSE_LIMIT = 1024`; `_pack` raises `ValueError("recursion limit exceeded.")` + when `nest_limit < 0` and decrements per container (_packer.pyx:42, 259-262; fallback.py:66, 699-711, 801-804). + +### 2.5 JavaScript + +- @msgpack/msgpack Decoder: iterative, with a heap `StackPool` (Decoder.ts:111-190, loop :640-643); + no depth option appears in the decoder (grep of "depth" returns only stack-pool lines). The + README option table lists `maxDepth` default 100 (README.md:119) and Encoder.ts uses it + (`DEFAULT_MAX_DEPTH = 100`, :9; `doEncode` throws `Too deep objects in depth N` when + `depth > maxDepth`, :173). So the library is asymmetric: encode bounded, decode unbounded in + depth (limited by memory). The CultLib question states TS "parse the whole payload"; consistent. +- msgpackr: grep of unpack.js and pack.js finds no maxDepth option or depth counter. +- msgpack5 (another npm package) GHSA-24ch-f2g6-9hhh (2026-10-08): "The decoder has no + nesting-depth limit for arrays and maps ... exhaust the JavaScript call stack"; fix in 6.1.0 + limits depth to 100 by default, option `maxDepth`, error "Maximum decode depth exceeded". +- msgpack-java: CVE record at vulncheck.com ("through 0.9.12", GitHub issue #1015): + `MessageUnpacker.unpackValue()` "recursively deserializes arrays and maps without nesting + depth limits", causing StackOverflowError. Fix value not retrieved. + +### 2.6 .NET JSON + +- System.Text.Json: `JsonReaderOptions.DefaultMaxDepth = 64` (JsonReaderOptions.cs:13); + `JsonWriterOptions.DefaultMaxDepth = 1000` (JsonWriterOptions.cs:18); + `JsonSerializerOptions.DefaultMaxDepth` = the reader's 64, with a comment that it is the + minimum of the reader and writer defaults (JsonSerializerOptions.cs:27-28). So the serializer + limit applies to both directions at 64; the bare writer at 1000. +- Newtonsoft.Json: `JsonReader` constructor sets `_maxDepth = 64` (JsonReader.cs:332); exceeding + throws `JsonReaderException` "The reader's MaxDepth of {0} has been exceeded" (:356-359). + `JsonWriter.cs` has no MaxDepth match. Advisory GHSA-5crp-9r3c-p9vr / CVE-2024-21907 + (published 2022-06-22, affects < 13.0.1): "Insecure Defaults ... improper handling of + expressions with high nesting level that lead to StackOverFlow exception or high CPU and RAM + usage". Advisory figures: deserialising needs >10 million nesting (9.5 MB of `{a:{a:`) for >10 s + of CPU; serialising (`SerializeObject`, `JObject.ToString`) overflowed the stack at about + 20,000 levels. The advisory's own mitigation example sets `MaxDepth = 128`; the 13.0.1 default + is 64 (the changelog line was not retrieved; the 64 is read from the code above). + +### 2.7 Protobuf + +- Default recursion limit 100 in C++ (coded_stream.h:630 comment "100 by default"), C# + (CodedInputStream.cs:57), Java (CodedInputStream.java:45) and upb (constants.h:11). C# rejects a + non-positive limit (cs:142-144); upb stores it in a 16-bit field (`upb_DecodeOptions_MaxDepth`). +- Skipped content: GHSA-735f-pc8j-v9w8 / CVE-2024-7254 (published 2024-09-19): in protobuf Java + (full and lite), Kotlin and JRuby, "Parsing nested groups as unknown fields with + DiscardUnknownFieldsParser or Java Protobuf Lite parser, or against Protobuf map fields, + creates unbounded recursions". Fixed in 3.25.5, 4.27.5, 4.28.2. The advisory implies unknown + (skipped) content had escaped the recursion limit until the fix. +- No write-side limit was looked up. + +### 2.8 CBOR, BSON, Go, simdjson + +- ciborium: `recurse: 256` in `from_reader` and `deserializer_from_reader` + (de/mod.rs:812, 854); `from_reader_with_recursion_limit` documents "Set a high recursion limit at + your own risk (of stack exhaustion)!" (:819-822, 859-862). `recurse()` errors + `RecursionLimitExceeded` at 0 (:64-74). No depth/recurs match in ser/mod.rs. +- cbor2: advisory GHSA-3c37-wwvx-h642 / CVE-2026-26209 (published 2026-03-23, fixed 5.9.0): + "uncontrolled recursion" in both the pure-Python and C decoders; PoC is 1000 nested `0x81` + bytes raising `RecursionError`. The C decoder used `Py_EnterRecursiveCall` (Python's + interpreter limit). Fix: `max_depth` parameter, default 400, in 5.9.0 (versionhistory.rst:172-173). + 6.0.0 (2026-04-28): decoder rewritten in Rust, iterative, depth "limited only by the available + memory" (versionhistory.rst:138-140). +- BSON: MongoDB documents "no more than 100 levels of nesting for BSON documents. Each object or + array adds a level." The page does not state read vs write. +- Go encoding/json: `maxNestingDepth = 10000` "limits the max nesting depth to prevent stack + overflow" (scanner.go:146-148); `pushParseState` errors "exceeded max depth" when the parse stack + exceeds it (:181-185). `checkValid` scans the whole input before decode (scanner.go:30), so every + container, including ones the target type ignores, is counted (inferred from `checkValid` being + the first step of `Unmarshal`; not read in decode.go). Encoder: `ptrLevel` over 1000 starts + cycle detection, which is not a depth limit (encode.go:298-305). +- simdjson: `constexpr size_t DEFAULT_MAX_DEPTH = 1024` (common_defs.h:49); `allocate(capacity, + max_depth = DEFAULT_MAX_DEPTH)`. (The PHP binding defaults to 512 per the PHP manual.) + +## 3. Read-side vs write-side, and skipped content + +Enforces on write: rmp-serde (variant defined, no construction found: no); msgpack-python Packer +(1024); @msgpack/msgpack Encoder (100); System.Text.Json (serializer 64, writer 1000); Go +encoding/json (no depth limit; cycle detection only); MessagePack-CSharp (no); serde_json (no); +ciborium (no); Newtonsoft (no match in JsonWriter.cs). + +Counts skipped/ignored content: rmp-serde (yes, via `ignored_any` to `deserialize_any`); +msgpack-python C unpacker (yes, `skip()` shares the stack); Go (scanner pre-pass, inferred); +serde_json (apparently no: `ignore_value` is iterative and uncounted, inferred); +MessagePack-CSharp (no: counting is by formatter contract); protobuf Java (unknown groups were +unbounded before CVE-2024-7254). + +## 4. Stack use per nesting level + +No library documents bytes of stack per nesting level. What exists: +- ciborium: "Set a high recursion limit at your own risk (of stack exhaustion)!" +- Newtonsoft advisory: serialisation overflowed the default stack at about 20,000 levels + (a count, not bytes; thread stack size not stated in the advisory). +- cbor2 advisory PoC: 1000 nested arrays reached the Python recursion limit. +- Rhai docs (search snippet, a different system): default call depth 64 in release, 8 in debug, + an illustration that debug frames are larger. +- CultLib's own measurement (from the question, not a prior-art source): Rust debug on 2 MiB + aborts at about 408 containers, which is about 5 KiB per level (2 MiB / 408, inferred); + release carries 1200+; Python near 1000; TS near 4000. +- serde_json and rmp-serde pick 128 and 1024 respectively; neither source documents how the value + was derived. Arrow Parquet Variant (docs.rs search result) says it picked MAX_NESTING_DEPTH 128 + "to match serde_json" because validation recurses. + +## 5. Not retrieved + +The rationale for 500 in MessagePack-CSharp; the Newtonsoft 13.0.1 release note; the fix depth +chosen by msgpack-java and serde-json-wasm; protobuf write-side behaviour; decode.go's use of +`checkValid`; MessagePack-CSharp writer code; simdjson's depth-error semantics.