Repository navigation
Description
Relationship to #819: #819 is a correctness bug β a tiny recursive-newtype-enum document overflows even at depth ~3 (the struct-variant $value form works fine at the same depth). This report is different and orthogonal: the working deserialization path (struct variants, or serde_json::Value via deserialize_any) has no recursion-depth cap, so an attacker-controlled deeply nested document overflows the native call stack β a DoS, not a type-shape bug. A fix for #819 (make newtype recursion terminate) would not address this; a depth cap would.
Mechanism
deserialize_struct (src/de/mod.rs) consumes a Start and calls visit_map(ElementMapAccess::new(..)) while its own frame stays live; a nested child re-enters deserialize_struct via ElementMapAccess::next_value_seed β MapValueDeserializer::deserialize_struct β self.map.de.deserialize_struct. Each XML nesting level adds several native stack frames. The only bound in de/ is event_buffer_size (the overlapped-lists skip buffer) β it does not bound call-stack depth. No recursive user type is even required: deserialize_any routes self-describing targets (e.g. serde_json::Value) through the same path.
The crash is an uncatchable abort (not a Result::Err), so a service that deserializes untrusted XML with the serialize feature has a pre-auth crash it cannot handle. Worker threads with smaller stacks fail far earlier than the 8 MB main thread.
How to reproduce
cargo new repro && cd repro, add quick-xml = { version = "0.41", features = ["serialize"] } and serde = { version = "1", features = ["derive"] }, drop this into src/main.rs, then cargo run --release:
use serde::Deserialize;
#[derive(Deserialize)]
struct S {
#[serde(default, rename = "$value")]
v: Vec<S>, // the struct-variant "$value" shape that #819 says WORKS
}
fn main() {
for depth in [3usize, 1000, 20_000, 60_000] {
let xml = "<a>".repeat(depth) + &"</a>".repeat(depth);
let _: Result<S, _> = quick_xml::de::from_str(&xml);
eprintln!("depth={depth} ({} bytes) -> returned", xml.len());
}
}Observed (release, default 8 MB main-thread stack):
depth=3 (21 bytes) -> returned
depth=1000 (7000 bytes) -> returned
thread 'main' has overflowed its stack
fatal runtime error: stack overflow, aborting
The key point vs #819: the struct-variant / $value shape #819 calls working deserializes correctly at shallow depth (3, 1000) but still aborts at attacker depth (~20 000, 140 KB) β so this is depth-driven resource exhaustion on the normal path, independent of the newtype-variant correctness bug.
Suggested direction
A configurable recursion-depth limit in the Deserializer that returns a clean Error past the cap β mirroring serde_json's Deserializer::recursion_limit and quick-xml's own event_buffer_size DoS guard.
Found by the rust-in-peace security pipeline.
Activity
scadastrangelove commentedon Jul 19, 2026
Comment on #978 β the missing recursion-depth cap also needs to cover
deserialize_seqand the enum pathRan a variant sweep for other recursion cycles in
de/that share this issue's "no depth cap" root cause(the
Deserializerstruct has no depth field; onlyevent_buffer_size/limit, which bounds theoverlapped-lists skip buffer, not call-stack depth). Two more cycles reproduce the same uncatchable
stack overflow, abortingabort as the one already reported here, through different serde entrypoints β worth folding into whatever fix lands for this issue, since a cap added only to the
deserialize_structβnext_value_seedcycle this issue describes would not cover them.1.
deserialize_seqcycle (aVec<Self>field β the natural shape for a list of same-typed nestedelements):
deserialize_seq(de/map.rs:607) βvisit_seq(MapValueSeqAccess)βnext_element_seed(
map.rs:954) βElementDeserializer::deserialize_struct(map.rs:1137) βvisitor.visit_map(ElementMapAccess::new(..))(map.rs:1146) β back intonext_value_seedβdeserialize_seq. One frame per nesting level.Depth 1,000 returns cleanly; depth 10,000 (~70 KB) β
thread 'main' has overflowed its stack / fatal runtime error: stack overflow, aborting.2. Enum
newtype_variant/struct_variant/tuple_variantcycle:deserialize_enum(
de/mod.rs:3295) βEnumAccess::variant_seed(de/var.rs:39) βVariantAccess::newtype_variant_seed(de/var.rs:105,seed.deserialize(self.de)) β re-entersdeserialize_enum.deserialize_any(henceserde_json::Value-shaped targets) routes through the sameenum/struct/seq machinery with no recursive user type required.
Same abort as above.
Both confirmed on
master, release build. Whatever depth-tracking mechanism ends up fixing the struct/mapcycle in this issue, it'll need to live somewhere shared (e.g. on the
Deserializeritself, checked atthe top of
deserialize_struct,deserialize_seq, anddeserialize_enum) so it catches all threere-entry points rather than just the one this issue was filed against.
Found with the rust-in-peace security pipeline.
dralley commentedon Jul 29, 2026
I'm working on a replacement PR that avoids a new type
Add recursion-depth limit to serde Deserializer
Add recursion-depth limit to serde Deserializer
Add recursion-depth limit to serde Deserializer
Add recursion-depth limit to serde Deserializer