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.
Relationship to #819: #819 is a correctness bug — a tiny recursive-newtype-enum document overflows even at depth ~3 (the struct-variant
$valueform works fine at the same depth). This report is different and orthogonal: the working deserialization path (struct variants, orserde_json::Valueviadeserialize_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 aStartand callsvisit_map(ElementMapAccess::new(..))while its own frame stays live; a nested child re-entersdeserialize_structviaElementMapAccess::next_value_seed→MapValueDeserializer::deserialize_struct→self.map.de.deserialize_struct. Each XML nesting level adds several native stack frames. The only bound inde/isevent_buffer_size(theoverlapped-listsskip buffer) — it does not bound call-stack depth. No recursive user type is even required:deserialize_anyroutes self-describing targets (e.g.serde_json::Value) through the same path.The crash is an uncatchable
abort(not aResult::Err), so a service that deserializes untrusted XML with theserializefeature 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, addquick-xml = { version = "0.41", features = ["serialize"] }andserde = { version = "1", features = ["derive"] }, drop this intosrc/main.rs, thencargo run --release:Observed (release, default 8 MB main-thread stack):
The key point vs #819: the struct-variant /
$valueshape #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
Deserializerthat returns a cleanErrorpast the cap — mirroringserde_json'sDeserializer::recursion_limitand quick-xml's ownevent_buffer_sizeDoS guard.Found by the rust-in-peace security pipeline.