fix(security): recursion/path-depth guards across VRL subsystems (batch C) - #9
Conversation
| const MAX_EXPR_DEPTH: u32 = 128; | ||
|
|
||
| fn compile_expr(&mut self, node: Node<ast::Expr>, state: &mut TypeState) -> Option<Expr> { | ||
| if self.depth >= Self::MAX_EXPR_DEPTH { |
There was a problem hiding this comment.
See if we can add stacker as a dep and grow stack when needed,
stacker::maybe_grow(32 * 1024, 8 * 1024 * 1024, ....
There was a problem hiding this comment.
Split this into its own PR — #13 — since it's a real design question (new dependency, growing vs. capping) that deserves its own discussion rather than riding along with this batch's unrelated fixes. Short version: I implemented stacker::maybe_grow with the parameters you suggested and it didn't actually solve the constrained-stack problem in testing — see #13's description for the details (something else that scales with nesting depth, most likely type_info()'s own recursion, isn't covered by wrapping just compile_expr). That PR keeps the hard cap as the sole fix for now; happy to keep discussing the stack-growth approach over there.
| ((index as usize) + 1).min(MAX_ARRAY_CAPACITY) | ||
| } else { | ||
| (-index) as usize | ||
| ((-index) as usize).min(MAX_ARRAY_CAPACITY) |
There was a problem hiding this comment.
Same fix as vrl/7
index.unsigned_abs().min(MAX_ARRAY_CAPACITY)
There was a problem hiding this comment.
Rebased onto latest main, which now includes #7 (batch J) — this branch had independently duplicated the exact same MAX_ARRAY_CAPACITY/MAX_ARRAY_INDEX fix before that PR merged. Dropped the duplicate entirely; no diff remains against main in insert.rs.
| } | ||
|
|
||
| fn insert_value(&mut self, key: isize, value: Value) -> Option<Value> { | ||
| const MAX_ARRAY_INDEX: isize = 32_768; |
There was a problem hiding this comment.
Prpogate this up, not sure if we fixed this vrl/7 but this seems like the same code path
There was a problem hiding this comment.
Same as insert.rs — resolved by rebasing onto main, which now has this exact fix from #7. No diff remains against main in crud/mod.rs.
| fn do_unflatten(value: Value, separator: &str, recursive: bool) -> Value { | ||
| match value { | ||
| Value::Object(map) => do_unflatten_entries(map, separator, recursive).into(), | ||
| Value::Object(map) => do_unflatten_entries(map, separator, recursive, 0).into(), |
There was a problem hiding this comment.
Do we need to reset depth to 0 here
There was a problem hiding this comment.
Good catch — yes, this was a real bug. do_unflatten re-enters itself (via recursive: true) whenever a value is already a nested Value::Object, not just when splitting dotted keys — and it was always passing depth 0 on that re-entry, so the cap never engaged for pre-nested input regardless of actual depth. Threaded the accumulated depth through do_unflatten/do_unflatten_entries/do_unflatten_entry instead of resetting it, and added test_unflatten_recursive_forwards_depth_obe10744, a differential test that fails against the old code and passes against the fix.
| /// Process an XML node, and return a VRL `Value`. | ||
| fn process_node(node: Node, config: &ParseXmlConfig) -> Value { | ||
| fn process_node(node: Node, config: &ParseXmlConfig, depth: u32) -> ExpressionResult<Value> { | ||
| if depth > MAX_XML_DEPTH { |
There was a problem hiding this comment.
Replace all similar checks with a function call something like
fn check_depth(depth: usize, max: usize, what: &str) -> Result<(), String> {
if depth > max {
return Err(format!("{what} nesting depth limit ({max}) exceeded"));
}
Ok(())
}
There was a problem hiding this comment.
This code is no longer part of the diff — main already has an equivalent (and better) depth guard here via #8/#10, so this branch's copy was dropped entirely on rebase (see the reply on the comment below). For the other sites this PR still touches (ruby_hash.rs, unflatten.rs), the depth checks return through different, incompatible error types (nom Failure, truncation-without-error) so a single shared check_depth helper doesn't cleanly cover them without forcing an abstraction across genuinely different control flow. Did apply the dedup idea where it was a true verbatim duplicate — see stdlib::util::check_path_segment_limit for set.rs/remove.rs below.
| fn set(path: Value, mut value: Value, data: Value) -> Resolved { | ||
| let path = match path { | ||
| Value::Array(segments) => { | ||
| if segments.len() > MAX_PATH_SEGMENTS { |
There was a problem hiding this comment.
See if we can reuse, it is exactly same in remove.rs and set.rs
There was a problem hiding this comment.
Deduped into stdlib::util::check_path_segment_limit (with the MAX_PATH_SEGMENTS constant), matching the existing DEFAULT_DECOMPRESS_LIMIT/DECOMPRESS_LIMIT_ERROR convention already in that module. Both set.rs and remove.rs now just call check_path_segment_limit(segments.len())?.
| process_node(node, config) | ||
| // Skip non-element/non-text nodes (e.g. comments, PIs) to prevent | ||
| // passing them to process_node which cannot handle them. | ||
| let child = node |
There was a problem hiding this comment.
Many of these changes for xml.rs are already merged on the main branch, these shouldn't be required anymore, Please check that also
There was a problem hiding this comment.
Confirmed and acted on — main already has an equivalent (and better) depth guard here, merged via #8/#10 while this branch was still on an older base commit. It also carries the OBE-10731 comment/PI fix this branch's version was missing (this branch's catch-all used unreachable!(); main's returns an empty object instead). Rebased onto main and dropped this branch's redundant reimplementation entirely — no diff remains against main in xml.rs.
…(OBE-10732 batch C) Close OBE-10732, OBE-10738, OBE-10739, OBE-10740, OBE-10741, OBE-10742, OBE-10744. All seven findings share a single root cause: no depth counter in VRL's parse, compile, and stdlib layers allowed attacker-controlled input to drive unbounded stack recursion. - compiler: reject programs with expression nesting > MAX_EXPR_DEPTH (128) at compile time; this also closes OBE-10740 because programs exceeding the cap never reach the runtime resolver. - parse_ruby_hash: thread a depth counter through parse_value/parse_hash/parse_array (nom closures) and return a hard error above MAX_RUBY_HASH_DEPTH (128). - parse_xml: process_node now carries a depth argument; returns ExpressionError above MAX_XML_DEPTH (128), propagated through parse_xml via ?. - unflatten: do_unflatten_entries bails at MAX_UNFLATTEN_DEPTH (128); do_unflatten_entry caps key splits via splitn(MAX_UNFLATTEN_DEPTH + 1, sep). - set / remove: reject caller-supplied paths longer than MAX_PATH_SEGMENTS (128) before any traversal is attempted. - value::Drop: iterative-drop approach removed; construction-time caps on all external input paths (parse_json via serde_json built-in limit, xml, ruby_hash, unflatten) prevent deeply-nested Values from being created, which eliminates the recursive-drop attack surface without the codebase-wide move-semantics breakage. Six RED security tests added — one per distinct fix site. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…(review feedback) Rebase onto the now-merged batch-J/8/10/11 fixes, which independently closed the crud/insert.rs, crud/mod.rs, and xml.rs (OBE-10742) overlap this branch had duplicated before those PRs landed on main. Also close a gap the rebase surfaced: do_unflatten always passed depth=0 when re-entering itself for recursive: true unflatten on pre-existing nested objects, so the MAX_UNFLATTEN_DEPTH cap never engaged for that path. Thread the accumulated depth through do_unflatten/do_unflatten_entries/do_unflatten_entry instead of resetting it. Dedup the identical MAX_PATH_SEGMENTS check in set.rs/remove.rs into stdlib::util::check_path_segment_limit, matching the existing DEFAULT_DECOMPRESS_LIMIT convention in that module.
Jagmeet suggested growing the stack with stacker::maybe_grow instead of a hard compile-time reject for compile_expr's depth guard. That's a real design choice (new dependency in a crate every downstream consumer depends on) worth its own review, not a drive-by addition to this batch. Pull the OBE-10738 depth-cap change (and the OBE-10740 coverage it implied) out of this PR; follow-up PR carries it forward with the stacker approach. Remaining tickets in this PR: OBE-10739, OBE-10741, OBE-10742, OBE-10744.
8de54d0 to
7c0507c
Compare
Why
Several OBE batch-C findings share one root cause: VRL's parse and stdlib layers recurse over
attacker-controlled nested input with no depth counter, so a crafted payload can drive the stack
arbitrarily deep and cause a crash or DoS.
What changed
src/parsing/ruby_hash.rsparse_value/parse_hash/parse_arrayconverted to depth-parameterised nom closures; returns hard failure aboveMAX_RUBY_HASH_DEPTH (128)src/stdlib/unflatten.rsdo_unflatten_entriesreturns early atMAX_UNFLATTEN_DEPTH (128);do_unflatten_entrycaps key splitting viasplitn. Depth is now threaded throughdo_unflatten/do_unflatten_entries/do_unflatten_entryinstead of being reset to 0 on eachrecursive: truere-entry — see review discussion belowsrc/stdlib/set.rs,src/stdlib/remove.rsMAX_PATH_SEGMENTS (128)before any traversal; check deduped intostdlib::util::check_path_segment_limit(was duplicated verbatim in both files)Changes made in response to review
src/value/value/crud/insert.rs,crud/mod.rs(OBE-10735/array-index cap): this branch had independently re-implemented the same fix already merged via fix(security): prevent 9 panic/OOM vectors in VRL runtime (batch J) #7 (batch J), before this branch was rebased onto a main that included it. Rebased onto latestmainand dropped the branch's own (worse) duplicate — no diff againstmainremains in these files.src/parsing/xml.rs(OBE-10742): same situation —mainalready has an equivalent (and better — it also carries the OBE-10731 comment/PI fix this branch's version was missing) depth guard, merged via Vrl panic safety #8/panic/OOM hardening #10 while this branch was still based on an older commit. No diff againstmainremains in this file.src/stdlib/unflatten.rs— depth reset: confirmed via review thatdo_unflattenwas hardcoding depth0on every recursive re-entry (triggered byrecursive: trueon values that are already-nested objects, not just dotted keys), so the depth cap never engaged for that path. Fixed by threading the accumulated depth through instead. Added a differential regression test (test_unflatten_recursive_forwards_depth_obe10744) that fails against the old (reset-to-0) code and passes against the fix.src/stdlib/set.rs/remove.rs— duplicate check: deduped the identicalMAX_PATH_SEGMENTScheck intostdlib::util::check_path_segment_limit, matching the existingDEFAULT_DECOMPRESS_LIMIT/DECOMPRESS_LIMIT_ERRORconvention already in that module.src/compiler/compiler.rs(OBE-10738/OBE-10740, compile-time expression depth cap): pulled out into a separate PR, fix(security): reject excessive VRL expression nesting at compile time (OBE-10738, OBE-10740) #13, since the review comment there raised a real design question (stacker::maybe_growvs. a hard cap) that deserved its own discussion rather than riding along with this batch's unrelated fixes. See fix(security): reject excessive VRL expression nesting at compile time (OBE-10738, OBE-10740) #13 for that discussion, including whystackeralone turned out not to be sufficient.Correction to this PR's original description
An earlier version of this description (and its title/commit) referenced OBE-10732 and a rewrite of
Value'sDropimpl insrc/value/value.rs. That code does not exist anywhere in this branch'shistory — it was aspirational text that was never implemented, and it contradicts this project's own
design doc (
docs/specs/2026-08-07-vrl-recursion-depth-caps.md), which explicitly scopes OBE-10732out of this PR:
Dropcan't return an error, so no input-side depth cap closes it, and the real fix(a manual iterative
Drop, and/orstacker) touchesValue's core trait impls broadly enough toneed its own dedicated spec and review. OBE-10732 is not fixed by this PR and should not be
treated as closed until that follow-up work happens.
Tickets closed by this PR
OBE-10739, OBE-10741, OBE-10744
Tickets already covered by
main(not by this PR, but no longer needed here)OBE-10731 (as part of
main's existing OBE-10742 fix), OBE-10735 (via #7), OBE-10742 (via #8/#10)Tickets split into a follow-up PR
OBE-10738, OBE-10740 — see #13
Tickets still open, not addressed by this PR
OBE-10732 — needs its own spec/PR (iterative
Drop/DisplayforValue, orstacker); explicitlyout of scope here, see correction above
Test plan
cargo test --lib: 1759 passed, 0 failedstdlib::ruby_hash::tests::test_depth_limit_obe10741— 200-level ruby hash rejectedstdlib::set::tests::test_path_length_limit_obe10739/stdlib::remove::tests::test_path_length_limit_obe10739— 200-segment path rejectedstdlib::unflatten::tests::test_depth_limit_obe10744— 200-level grouped unflatten completes with bounded nestingstdlib::unflatten::tests::test_unflatten_recursive_forwards_depth_obe10744— new; regression test for the depth-reset bug found in reviewcargo clippy --lib: no new warnings (3 pre-existing, unrelated failures onmainuntouched by this diff)🤖 Generated with Claude Code