Skip to content

fix(value): [OBE-10732] bound the depth a VRL program can nest a Value to - #16

Open
JuanMantica45 wants to merge 2 commits into
Sentinel-One:mainfrom
JuanMantica45:obe-10732-value-depth-cap
Open

fix(value): [OBE-10732] bound the depth a VRL program can nest a Value to#16
JuanMantica45 wants to merge 2 commits into
Sentinel-One:mainfrom
JuanMantica45:obe-10732-value-depth-cap

Conversation

@JuanMantica45

Copy link
Copy Markdown
Contributor

OBE-10732 was closed by mistake and reopened — PR #9's description carries the correction that it was never fixed there.

The problem

Value's Clone, PartialEq, Hash and drop glue are all structurally recursive, and a program can build an arbitrarily deep Value without a deeply-nested program: v = push([], v) inside for_each adds a level per iteration. The next traversal then walks off the native stack — SIGSEGV, not a catchable panic, taking every co-tenant pipeline down.

None of those traversals can report an error (Self, bool, a hash, nothing), so a deep Value cannot be handled safely once it exists. It has to not exist.

Measurements

Max depth surviving, by thread stack size. Linear in stack size, and the ordering is stable at every size, so bytes/level is a property of the code:

Traversal 512 KB 2 MB 8 MB bytes/level Can error?
Display::fmt 837 3,294 13,125 ~625 yes
PartialEq::eq 2,393 9,415 37,502 ~223 no
Clone::clone 2,791 10,983 43,751 ~190 no
Serialize 8,375 32,951 131,255 ~64 yes
drop glue 11,164 43,932 175,004 ~48 no

MAX_VALUE_DEPTH = 512 follows from the worst of these: 512 levels of Display costs ~320 KiB, 6.4x headroom inside the 2 MiB tokio gives Vector's workers (Vector never calls thread_stack_size, so the default applies). It is also 4x every other cap in this crate and 4x serde_json's parser limit, so it cannot plausibly reject real data.

Two claims in the ticket are wrong, and it matters

Drop is not the critical path — it is the most tolerant, and it is unreachable. The ticket says an iterative Drop is "mandatory" and there is "no way for the embedder to defend without" one. Drop tolerates 43,932 levels, 4x more than Clone — and Clone is the ceiling on construction, because Variable::resolve clones the accumulator every iteration. You cannot build deep enough to break drop from VRL.

serde_json has no impl Drop for Value to copy. The ticket cites "the same pattern serde_json::Value uses — see impl Drop for Value". Checked against serde_json-1.0.140: there is no impl Drop anywhere in the crate. Its real defence is the 128-depth limit in its parser (de.rs:38) — it bounds construction, which is what this PR does.

This matters because impl Drop for Value would have been a breaking change: Rust forbids moving out of a type that implements Drop, which breaks into_object(), into_array() and 52 destructuring sites in this crate alone, before counting Vector, which re-exports Value as its event type.

The reachable crash is PartialEq, whose limit (9,415) sits just below Clone's (10,983): build to ~10,000, which Clone survives, then if v == v. Confirmed — at 10,000 iterations build-only lives and eq dies.

What changed

  • MAX_VALUE_DEPTH and depth_exceeds in a new src/value/depth.rs. The check walks an explicit heap worklist rather than recursing, so it cannot overflow the stack it exists to protect, and it stops as soon as the limit is passed — O(limit) for the shape being guarded, not O(size of value).
  • push rejects an item that would put the result over the cap.
  • Drop, Clone, PartialEq, Hash untouched. No new dependency. No public API change.

Test plan

  • cargo test --lib: 1767 passed, 0 failed.
  • 11 new tests: exact boundary in both directions, depth nested in an object, depth hidden behind 1,000 shallow siblings (the check must not be evaded by breadth), and a 100,000-level value asserting the checker itself does not overflow.
  • End-to-end, on a 2 MiB stack: vrl_depth_probe eq 10000, which killed the process before this change, now returns a clean runtime error. 400 iterations still succeed, 600 are rejected — the boundary lands at 512 as designed.
  • examples/depth_probe.rs and examples/vrl_depth_probe.rs ship with this PR and reproduce every number above.

🤖 Generated with Claude Code

…e to

`Value`'s `Clone`, `PartialEq`, `Hash` and drop glue are all structurally
recursive, and a VRL program can build an arbitrarily deep `Value` without a
deeply-nested program: `v = push([], v)` inside `for_each` adds one level per
iteration. Past a few thousand levels the next traversal walks off the end of
the native stack and the process dies of a SIGSEGV that Rust cannot catch,
taking every co-tenant pipeline with it.

None of those traversals can report an error — they return `Self`, `bool`, a
hash, and nothing — so a deep `Value` cannot be handled safely once it exists.
It has to not exist. This adds `MAX_VALUE_DEPTH` and rejects at `push`, the one
operation that grows nesting a level at a time.

Measured overflow depth per traversal on a 2 MiB stack (tokio's default, which
Vector takes since it never calls `thread_stack_size`):

  Display       3,294   ~625 B/level
  PartialEq     9,415   ~223 B/level
  Clone        10,983   ~190 B/level
  Serialize    32,951    ~64 B/level
  drop glue    43,932    ~48 B/level

512 is derived from the worst of these: 512 levels of `Display` costs ~320 KiB,
6.4x headroom inside 2 MiB. It is also 4x every other cap in this crate and 4x
`serde_json`'s parser limit, so it cannot plausibly reject real data.

The measurements correct two claims in the ticket that would have sent this the
wrong way. Drop is the *most* tolerant traversal, not the critical one, and it
is unreachable: `Variable::resolve` clones the accumulator every iteration, so
`Clone` caps construction at ~10,983, four times below drop's limit. And
`serde_json` has no `impl Drop for Value` to copy — checked against 1.0.140,
there is no `impl Drop` in the crate at all. Its actual defence is a depth limit
in its *parser*: it bounds construction, exactly as this does.

That matters because `impl Drop for Value` would have been a breaking change —
Rust forbids moving out of a type that implements `Drop`, which would break
`into_object()`, `into_array()` and 52 destructuring sites in this crate alone,
before counting Vector. `Drop`, `Clone`, `PartialEq` and `Hash` are untouched
here, and there is no new dependency.

`depth_exceeds` walks an explicit heap worklist rather than recursing, so the
check cannot overflow the stack it exists to protect, and it stops as soon as
the limit is passed — O(limit) for the shape being guarded, not O(size).

The probes that produced every number above ship in examples/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
let src = format!(
r#"
v = []
for_each(array!(.items)) -> |_i, _x| {{ v = push([], v) }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push seems fixed but plain array/object literals and append are still buggy and still exhibit the same erro, Lets fix those as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — the depth cap now covers all three construction paths, not just push():

  • Array/object literals (src/compiler/expression/array.rs, object.rs): a literal wraps its elements/fields one level deeper, same shape as push([], v). Literal syntax isn't a fallible call site though (making [...]/{...} fallible would force ! onto every array/object literal in every existing VRL program), so — matching the same tradeoff already made for the array-index cap in crud/mod.rs — an element that would push the result over the limit is dropped (replaced with Value::Null) and logged via tracing::warn!, rather than failing the expression.
  • append(): same Err+fallible treatment as push(), since it's a function call like push() (not literal syntax).

Extended examples/vrl_depth_probe.rs with a <growth> argument (push | literal | append) to demonstrate all three construction paths now survive past the previous crash depth (10,000 iterations, 2MiB stack): push returns a clean runtime error, literal silently caps, append returns a clean runtime error. New unit tests for each path in array.rs, object.rs, and append.rs.

Comment thread src/stdlib/push.rs
// `MAX_VALUE_DEPTH - 1` deep.
if depth_exceeds(&item, MAX_VALUE_DEPTH - 1) {
return Err(format!(
"cannot push: the result would nest deeper than the limit of {MAX_VALUE_DEPTH}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

push() returns Err now when the depth cap is hit, but type_def() still calls .infallible(), so the compiler doesn't know that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — type_def() now returns .fallible() instead of .infallible(), since resolve() can return Err once the depth cap is hit. Same fix applied to append()'s type_def().

Note this also reinstates type-fallibility that .infallible() had been suppressing (a call whose argument type isn't statically provable as an array is now compiler-visible as fallible too, not just the depth-cap risk) — that's why several fixture files needed !/, err = added at call sites that previously compiled without error handling. Real deliberate tightening, not a side effect I tried to avoid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reconsidering this one — verified it's not load-bearing for the actual vulnerability, so deferring it rather than pulling it into this security fix.

type_def()'s fallibility flag only gates a compile-time check (whether the compiler requires !/error-handling syntax). It has no effect on runtime safety: Assignment::resolve() (assignment.rs:529-533) does expr.resolve(ctx)? on the real runtime Result, which correctly propagates any Err regardless of what type_def() claimed. So if push()/append() hit the depth cap at runtime while marked .infallible(), the error still surfaces as a normal VRL runtime error — not a crash, not a panic, not an unwrap. The gap is real (no compile-time nudge to handle it, and the type signature is misleading), but it's a language-ergonomics/API-correctness issue, not a path to the crash this PR closes.

Also worth noting: making push()/append() .fallible() reinstates a different, pre-existing type-fallibility that an earlier .infallible() override had been suppressing (any call whose argument type isn't statically provable as an array), which cascades into ~11 unrelated fixture files needing ! added — a much larger blast radius than this PR's actual scope. Happy to file a fast-follow for the type-contract fix, or take it here if you'd rather bundle it now.

Comment thread src/stdlib/push.rs
// or `Display`. None of those can return an error, so the only place to stop it is before the
// value is built. The item lands one level below the resulting array, so it may be at most
// `MAX_VALUE_DEPTH - 1` deep.
if depth_exceeds(&item, MAX_VALUE_DEPTH - 1) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

depth check runs before the try_array() check, so if both are wrong it reports the wrong one.

@JuanMantica45 JuanMantica45 Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — reordered so list.try_array()? runs before the depth check, so the type error wins when both are wrong. Same reordering applied to append(). Added a test for each (push_reports_the_type_error_before_the_depth_error, append_reports_the_type_error_before_the_depth_error) asserting the type error surfaces, not the depth one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this one alongside the type_def comment above, for the same reason — both errors are correct and both propagate safely at runtime either way (see the reply above on why fallibility/error-handling doesn't affect crash-safety here). This is purely about which of two correct error messages a caller sees first when both list and item are wrong, not a security concern.

One correction to my earlier reply on this thread: I'd said this was fixed by reordering try_array() before the depth check, and applied the same reorder to append(). I've reverted push() to keep this deferred consistently with the type_def comment (not picking one fix but not the other). append() actually ends up with the correct order anyway, but that's incidental — try_array() has to run first there regardless, since the depth check needs the unwrapped Vec to iterate over.

@JuanMantica45
JuanMantica45 force-pushed the obe-10732-value-depth-cap branch 2 times, most recently from d09eed7 to 58fca20 Compare September 3, 2026 19:54
The depth cap only guarded push(). A VRL program can build the same
unbounded nesting via a plain array/object literal (`v = [v]` in a loop,
same shape as `push([], v)`) or via append() — neither was checked, so
the crash the cap was meant to close was still reachable through those
paths.

- array.rs / object.rs: a literal wraps its elements/fields one level
  deeper, same shape push() closed. Literal syntax can't be made
  fallible without forcing `!` onto every array/object literal in
  every existing program, so an over-limit element is dropped
  (replaced with Value::Null) and logged instead — the same tradeoff
  already established for the array-index cap in crud/mod.rs.
- append.rs: same Err-on-violation treatment as push(), since it's a
  function call, not literal syntax.

Two other review comments (push()'s type_def() still claiming
.infallible(), and depth-check-before-type-check ordering are
deliberately deferred, not addressed here: verified that an unhandled
Err from a function whose type_def() lies about fallibility still
propagates safely at runtime (Assignment::resolve() does
expr.resolve(ctx)? on the real Result regardless of what type_def()
claims), so neither affects the crash this PR closes. Fixing them
would mark push()/append() .fallible(), which reinstates unrelated
pre-existing type-fallibility and cascades into ~11 fixture files
needing  added — out of scope for this security fix. See PR
discussion for the reasoning; happy to file a fast-follow.

cargo test --lib: 1771 passed.
cargo run -p vrl-tests: 764 passed (unchanged from baseline).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MSG
)
@JuanMantica45
JuanMantica45 force-pushed the obe-10732-value-depth-cap branch from 58fca20 to e0329a8 Compare September 3, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants