Skip to main content

stellar_agent_toolsets/
validate.rs

1//! Pre-canonicalisation argument validation for toolset tool dispatch.
2//!
3//! The public entry point is [`validate_toolset_tool_args`].  It performs an
4//! iterative, depth-bounded, node-count-bounded walk over a `serde_json::Value` to:
5//!
6//! 1. Reject any argument object that contains a JS-runtime-dangerous key
7//!    (the denylist; see [`ARGS_KEY_DENYLIST`]) at ANY depth, including keys
8//!    inside objects nested within arrays.
9//!
10//! 2. Reject payloads that nest deeper than [`TOOLSET_ARGS_MAX_DEPTH`].
11//!
12//! 3. Reject payloads whose total node count exceeds [`TOOLSET_ARGS_MAX_NODES`].
13//!    This closes the O(payload-width) unbounded case that the depth bound alone
14//!    does not prevent (a flat object with millions of keys passes depth-1 but
15//!    would queue millions of stack entries before the denylist check runs).
16//!
17//! ## Why this guard exists
18//!
19//! When a JS extension attaches a `toJSON` method to an argument object, the
20//! value that PASSES validation differs from the value DISPATCHED after
21//! `JSON.stringify` (the `toJSON` hook runs during serialisation).  In a
22//! `serde_json::Value` world there is no live `toJSON` method — a `Value` is
23//! inert data — so the Rust realisation of that invariant is: **the exact
24//! in-memory `Value` validated here is the one moved into dispatch with no
25//! re-parse**.  The guard is still necessary because:
26//!
27//! - Our canonical JSON output is consumed DOWNSTREAM by a JS agent runtime.
28//! - A `toJSON` key in the serialised JSON output enables the serialisation-hook
29//!   bypass in the downstream runtime.
30//! - Dangerous keys (`then`, `__proto__`, etc.) enable thenable-hijack and
31//!   prototype-pollution attacks downstream.
32//!
33//! Rejecting these keys at the Rust validation layer prevents our serialised
34//! output from carrying them.
35//!
36//! ## Walk strategy
37//!
38//! The walk is ITERATIVE — it uses an explicit heap-allocated work-stack
39//! (`Vec<(&Value, usize)>`) rather than native C-stack recursion.  This prevents
40//! stack overflow on adversarially-deep payloads.
41//!
42//! Note: `serde_json`'s own parse recursion limit (~128 on most builds) already
43//! rejects pathologically-deep JSON before our layer, but a `Value` constructed
44//! in-memory (e.g. in a unit test or by a future refactor) can exceed that limit.
45//! The iterative walk is therefore bounded independently of the parse path.
46//!
47//! ## Mutation-before-guard invariant
48//!
49//! The caller MUST pass the FINAL post-injection `Value` to this function.  ALL
50//! mutation (`chain_id` injection, `envelope_xdr` insertion, any future merge)
51//! happens BEFORE this call; no insert/merge/serde round-trip occurs between this
52//! function and `from_value::<TypedArgs>`.  This is the "freeze before dispatch"
53//! invariant: the validated `Value` is the dispatched `Value`.
54
55use crate::args_error::ToolsetArgsError;
56
57// ── Public constants ──────────────────────────────────────────────────────────
58
59/// The denylist of JS-runtime-dangerous object-key names.
60///
61/// Any `serde_json::Value::Object` key that byte-exactly matches one of these
62/// strings at ANY depth (including inside arrays) causes
63/// [`validate_toolset_tool_args`] to return `Err(ToolsetArgsError::DangerousKey)`.
64///
65/// ## Matching is post-parse, exact-byte
66///
67/// `serde_json` decodes JSON unicode escapes (e.g. `__` → `_`) before
68/// our walk runs.  Matching against the already-decoded key string means
69/// escape-variant evasion collapses to the literal key — `__proto__`
70/// IS caught as `__proto__`.  This soundness property holds ONLY because the
71/// walk runs POST-PARSE; moving this guard upstream of `serde_json` parsing
72/// would re-open the escape evasion vector.
73///
74/// ## Why these 11 keys
75///
76/// - `toJSON` — custom serialisation hook; overrides `JSON.stringify`/`toJSON`.
77///   The value passed validation is NOT the value dispatched after stringify.
78/// - `then` — presence makes an object "thenable"; hijacks `await`/`Promise.resolve`.
79/// - `__proto__` — prototype chain pollution via `Object.assign` or spread (`{...}`).
80/// - `constructor` — class hierarchy tampering (e.g. `obj.constructor.prototype`).
81/// - `prototype` — direct prototype-chain pollution via `constructor.prototype`.
82/// - `toString` — coercion hook; invoked in string contexts (`""+obj`).
83/// - `valueOf` — coercion hook; invoked in numeric contexts (`+obj`, comparisons).
84/// - `__defineGetter__` — `Object.prototype` accessor injection; defines a getter.
85/// - `__defineSetter__` — `Object.prototype` accessor injection; defines a setter.
86/// - `__lookupGetter__` — `Object.prototype` accessor inspection; exposes getter.
87/// - `__lookupSetter__` — `Object.prototype` accessor inspection; exposes setter.
88///
89/// No legitimate matrix-tool argument field collides with any of these names
90/// (`chain_id`, `source`, `destination`, `amount`, `account_id`, `envelope_xdr`,
91/// and all SEP tool arg names).  A future collision requires an explicit
92/// allowlist exception with a decision-record entry.
93pub const ARGS_KEY_DENYLIST: &[&str] = &[
94    "toJSON",
95    "then",
96    "__proto__",
97    "constructor",
98    "prototype",
99    "toString",
100    "valueOf",
101    "__defineGetter__",
102    "__defineSetter__",
103    "__lookupGetter__",
104    "__lookupSetter__",
105];
106
107/// Maximum nesting depth for toolset tool argument payloads.
108///
109/// A payload nested deeper than this value is rejected with
110/// [`ToolsetArgsError::NestingTooDeep`].
111///
112/// ## Sizing rationale
113///
114/// The deepest legitimate matrix-tool argument shapes are flat or single-level:
115///
116/// - `StellarBalancesArgs` — flat: `{ account_id, chain_id? }`.
117/// - `StellarPayArgs` — flat: `{ source, destination, asset?, amount?,
118///   amount_in_stroops?, memo?, memo_type?, chain_id? }`.
119/// - `StellarPayCommitArgs` — flat: `{ source?, destination, asset?, amount?,
120///   amount_in_stroops?, memo?, memo_type?, chain_id?, envelope_xdr,
121///   approval_nonce?, approval_attestation? }`.
122/// - SEP tool args — flat to single nested object for metadata.
123///
124/// Setting `TOOLSET_ARGS_MAX_DEPTH = 16` is 15x the deepest legitimate depth,
125/// providing headroom for future SEP tool args that may carry one or two levels
126/// of nesting, while remaining well below `serde_json`'s parse limit (~128) and
127/// far below stack-overflow territory for the iterative walk.
128///
129/// ## Note on parse limit
130///
131/// `serde_json` rejects pathologically-deep JSON at parse time (typically at
132/// depth ~128).  Our walk is bounded independently so it cannot overflow even
133/// on a `Value` constructed directly in memory (bypassing the parse limit).
134///
135/// ## Distinct from `parse.rs::MAX_DEPTH`
136///
137/// `parse.rs::MAX_DEPTH = 8` is the YAML frontmatter nesting bound for TOOLSET.md
138/// parse.  That constant is in a different structural domain (YAML frontmatter
139/// fields) and is deliberately too small for JSON tool args (which can
140/// legitimately nest slightly deeper in SEP metadata objects).  Do NOT reuse it.
141pub const TOOLSET_ARGS_MAX_DEPTH: usize = 16;
142
143/// Maximum total node count for toolset tool argument payloads.
144///
145/// A payload whose total number of visited nodes (objects, arrays, and scalars
146/// combined) exceeds this value is rejected with [`ToolsetArgsError::TooManyNodes`].
147///
148/// ## Why this bound is necessary
149///
150/// [`TOOLSET_ARGS_MAX_DEPTH`] prevents deep payloads from exceeding the depth bound
151/// but does NOT bound WIDTH: a flat `Value::Object` with N million entries passes
152/// depth-1 but would enqueue N million `&Value` references onto the work-stack in
153/// a single loop iteration.  This node-count cap closes the O(payload-width)
154/// unbounded case.
155///
156/// The MCP transport bounds message size via the frame-size limit, so a real MCP
157/// caller cannot craft a million-entry object.  However, the CLI `--args` consumer
158/// will NOT have that frame-size guard.  This cap ensures the walk is bounded on
159/// both transports.
160///
161/// ## Sizing rationale
162///
163/// The widest legitimate matrix-tool argument payload is `StellarPayCommitArgs`
164/// (~12 flat fields) or the SEP tool args (~20 fields with a one-level-deep
165/// metadata sub-object, ~40 total nodes).  Setting `TOOLSET_ARGS_MAX_NODES = 1_024`
166/// is ~25× the widest legitimate payload, providing headroom for future tool args
167/// that may carry larger flat maps or short arrays, while remaining small enough
168/// to bound the walk to a trivial per-call cost.
169pub const TOOLSET_ARGS_MAX_NODES: usize = 1_024;
170
171// ── Public API ────────────────────────────────────────────────────────────────
172
173/// Validate a toolset tool argument payload before dispatch.
174///
175/// Performs an iterative, depth-bounded walk over `args`:
176///
177/// - `Value::Object`: each key is checked against [`ARGS_KEY_DENYLIST`].  A
178///   match returns `Err(ToolsetArgsError::DangerousKey { matched_key })` where
179///   `matched_key` is the matched `&'static str` constant (NOT the input key
180///   string — the error is redaction-clean).  Object values are pushed onto the
181///   work-stack for further traversal.
182/// - `Value::Array`: each element is pushed onto the work-stack.  Objects
183///   nested inside arrays ARE key-checked when popped.
184/// - `Value::String` / `Value::Number` / `Value::Bool` / `Value::Null`:
185///   no-op (scalars carry no keys to check).
186/// - Depth > [`TOOLSET_ARGS_MAX_DEPTH`]: returns
187///   `Err(ToolsetArgsError::NestingTooDeep)`.
188/// - Total nodes visited > [`TOOLSET_ARGS_MAX_NODES`]: returns
189///   `Err(ToolsetArgsError::TooManyNodes)`.
190///
191/// The walk is allocation-light: the work-stack holds `&Value` BORROWS of the
192/// original tree (no cloning of the payload).  The stack capacity is bounded by
193/// `TOOLSET_ARGS_MAX_NODES`.
194///
195/// ## Caller contract (mutation-before-guard invariant)
196///
197/// The caller MUST pass the FINAL post-injection `Value` (after all `chain_id`
198/// and `envelope_xdr` insertions).  No further mutation or serde round-trip
199/// should occur between this call and `serde_json::from_value::<TypedArgs>`.
200///
201/// # Errors
202///
203/// - [`ToolsetArgsError::DangerousKey`] — a key matching [`ARGS_KEY_DENYLIST`]
204///   was found at any depth (including nested in arrays).
205/// - [`ToolsetArgsError::NestingTooDeep`] — payload nesting exceeds
206///   [`TOOLSET_ARGS_MAX_DEPTH`].
207/// - [`ToolsetArgsError::TooManyNodes`] — total nodes visited exceeds
208///   [`TOOLSET_ARGS_MAX_NODES`].
209///
210/// # Examples
211///
212/// ```
213/// use stellar_agent_toolsets::validate_toolset_tool_args;
214/// use serde_json::json;
215///
216/// // Benign payload passes.
217/// let ok = validate_toolset_tool_args(&json!({ "account_id": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" }));
218/// assert!(ok.is_ok());
219///
220/// // Dangerous key rejected.
221/// let err = validate_toolset_tool_args(&json!({ "toJSON": "value" }));
222/// assert!(err.is_err());
223/// ```
224pub fn validate_toolset_tool_args(args: &serde_json::Value) -> Result<(), ToolsetArgsError> {
225    // Iterative work-stack: each entry is (value_ref, current_depth).
226    // The root is at depth 0; each Object/Array nesting increments depth by 1
227    // when pushing children.
228    let mut stack: Vec<(&serde_json::Value, usize)> = Vec::new();
229    stack.push((args, 0));
230
231    // Total nodes visited (popped from the stack).  Bounds the O(payload-width)
232    // case that TOOLSET_ARGS_MAX_DEPTH alone does not prevent: a flat object with
233    // N million keys passes depth-1 but would enqueue N million refs.
234    // Checked at the top of each iteration, before any work on the node.
235    let mut nodes_visited: usize = 0;
236
237    while let Some((value, depth)) = stack.pop() {
238        // Node-count bound: checked first so the per-node cost is O(1).
239        nodes_visited = nodes_visited.saturating_add(1);
240        if nodes_visited > TOOLSET_ARGS_MAX_NODES {
241            return Err(ToolsetArgsError::TooManyNodes {
242                count_limit: TOOLSET_ARGS_MAX_NODES,
243            });
244        }
245
246        // Depth bound check: if the CURRENT node is at depth > MAX, reject.
247        // The walk short-circuits here; the exact tripping depth is reported.
248        if depth > TOOLSET_ARGS_MAX_DEPTH {
249            return Err(ToolsetArgsError::NestingTooDeep {
250                depth,
251                max_depth: TOOLSET_ARGS_MAX_DEPTH,
252            });
253        }
254
255        match value {
256            serde_json::Value::Object(map) => {
257                for (key, child) in map {
258                    // Check key against denylist.  Match is exact-byte against the
259                    // already-serde-decoded key string.  The error carries the
260                    // matched `&'static str` constant (NOT `key`) — redaction-clean.
261                    if let Some(matched) = denylist_match(key.as_str()) {
262                        return Err(ToolsetArgsError::DangerousKey {
263                            matched_key: matched,
264                        });
265                    }
266                    // Push the value for further traversal.
267                    stack.push((child, depth + 1));
268                }
269            }
270            serde_json::Value::Array(elements) => {
271                // No key check on array indices; push elements for traversal.
272                // Objects nested inside arrays ARE key-checked when popped.
273                for element in elements {
274                    stack.push((element, depth + 1));
275                }
276            }
277            // Scalars (String, Number, Bool, Null): no keys to check, no children.
278            _ => {}
279        }
280    }
281
282    Ok(())
283}
284
285// ── Internal helpers ──────────────────────────────────────────────────────────
286
287/// Check `key` against [`ARGS_KEY_DENYLIST`] and return the matched `&'static str`
288/// constant if found, or `None` if the key is not in the denylist.
289///
290/// The returned value is the `&'static str` from the denylist slice, NOT the
291/// input key string.  Callers use this to build a redaction-clean error.
292#[inline]
293fn denylist_match(key: &str) -> Option<&'static str> {
294    ARGS_KEY_DENYLIST
295        .iter()
296        .copied()
297        .find(|&denied| key == denied)
298}
299
300// ── Unit tests ────────────────────────────────────────────────────────────────
301
302#[cfg(test)]
303mod tests {
304    #![allow(
305        clippy::unwrap_used,
306        clippy::expect_used,
307        clippy::panic,
308        reason = "test-only; panics and unwraps acceptable in unit tests"
309    )]
310
311    use serde_json::{Value, json};
312
313    use super::*;
314
315    // ── Helper: assert DangerousKey with matched constant ─────────────────────
316
317    fn assert_dangerous_key(result: &Result<(), ToolsetArgsError>, expected_key: &str) {
318        match result {
319            Err(ToolsetArgsError::DangerousKey { matched_key }) => {
320                assert_eq!(
321                    *matched_key, expected_key,
322                    "expected matched_key = {expected_key:?}, got {matched_key:?}"
323                );
324                // Verify Display does NOT contain any attacker-supplied input.
325                // (The error references the &'static str constant only.)
326                let display = result.as_ref().unwrap_err().to_string();
327                assert!(
328                    display.contains(expected_key),
329                    "Display must mention the matched constant: {display}"
330                );
331            }
332            other => panic!("expected DangerousKey({expected_key}), got {other:?}"),
333        }
334    }
335
336    // ── Benign payloads pass ──────────────────────────────────────────────────
337
338    #[test]
339    fn benign_flat_object_passes() {
340        let val = json!({
341            "account_id": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN",
342            "chain_id": "stellar:testnet"
343        });
344        validate_toolset_tool_args(&val).unwrap();
345    }
346
347    #[test]
348    fn benign_null_passes() {
349        validate_toolset_tool_args(&Value::Null).unwrap();
350    }
351
352    #[test]
353    fn benign_string_passes() {
354        validate_toolset_tool_args(&Value::String("hello".into())).unwrap();
355    }
356
357    #[test]
358    fn benign_array_of_objects_passes() {
359        let val = json!([
360            { "account_id": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" },
361            { "chain_id": "stellar:testnet" }
362        ]);
363        validate_toolset_tool_args(&val).unwrap();
364    }
365
366    #[test]
367    fn benign_nested_at_max_depth_passes() {
368        // Build a JSON object nested so that the deepest OBJECT is at depth
369        // TOOLSET_ARGS_MAX_DEPTH - 1.  The walk pushes child scalars at depth
370        // TOOLSET_ARGS_MAX_DEPTH, and `TOOLSET_ARGS_MAX_DEPTH > TOOLSET_ARGS_MAX_DEPTH`
371        // is false, so they pass.
372        //
373        // Depth accounting in the iterative walk:
374        //   - root (outer wrapper) is popped at depth 0; its child pushed at depth 1.
375        //   - each nested object is popped at its own depth D; its child pushed at D+1.
376        //   - The depth bound fires when `D > TOOLSET_ARGS_MAX_DEPTH`, i.e. D >= 17.
377        //
378        // Wrapping TOOLSET_ARGS_MAX_DEPTH - 1 = 15 times gives innermost object at
379        // depth 15, whose child scalar is pushed at depth 16 = TOOLSET_ARGS_MAX_DEPTH.
380        // `16 > 16` is false -> scalar is a no-op -> payload passes.
381        let mut val = json!({ "leaf": "value" });
382        for _ in 0..TOOLSET_ARGS_MAX_DEPTH - 1 {
383            val = json!({ "nested": val });
384        }
385        validate_toolset_tool_args(&val).unwrap();
386    }
387
388    // ── Denylist: each of the 11 keys at top level ────────────────────────────
389
390    #[test]
391    fn denylist_tojson_top_level() {
392        let val = json!({ "toJSON": "something" });
393        let r = validate_toolset_tool_args(&val);
394        assert_dangerous_key(&r, "toJSON");
395    }
396
397    #[test]
398    fn denylist_then_top_level() {
399        let val = json!({ "then": "something" });
400        let r = validate_toolset_tool_args(&val);
401        assert_dangerous_key(&r, "then");
402    }
403
404    #[test]
405    fn denylist_proto_top_level() {
406        let val = json!({ "__proto__": { "isAdmin": true } });
407        let r = validate_toolset_tool_args(&val);
408        assert_dangerous_key(&r, "__proto__");
409    }
410
411    #[test]
412    fn denylist_constructor_top_level() {
413        let val = json!({ "constructor": "something" });
414        let r = validate_toolset_tool_args(&val);
415        assert_dangerous_key(&r, "constructor");
416    }
417
418    #[test]
419    fn denylist_prototype_top_level() {
420        let val = json!({ "prototype": "something" });
421        let r = validate_toolset_tool_args(&val);
422        assert_dangerous_key(&r, "prototype");
423    }
424
425    #[test]
426    fn denylist_tostring_top_level() {
427        let val = json!({ "toString": "something" });
428        let r = validate_toolset_tool_args(&val);
429        assert_dangerous_key(&r, "toString");
430    }
431
432    #[test]
433    fn denylist_valueof_top_level() {
434        let val = json!({ "valueOf": "something" });
435        let r = validate_toolset_tool_args(&val);
436        assert_dangerous_key(&r, "valueOf");
437    }
438
439    #[test]
440    fn denylist_define_getter_top_level() {
441        let val = json!({ "__defineGetter__": "something" });
442        let r = validate_toolset_tool_args(&val);
443        assert_dangerous_key(&r, "__defineGetter__");
444    }
445
446    #[test]
447    fn denylist_define_setter_top_level() {
448        let val = json!({ "__defineSetter__": "something" });
449        let r = validate_toolset_tool_args(&val);
450        assert_dangerous_key(&r, "__defineSetter__");
451    }
452
453    #[test]
454    fn denylist_lookup_getter_top_level() {
455        let val = json!({ "__lookupGetter__": "something" });
456        let r = validate_toolset_tool_args(&val);
457        assert_dangerous_key(&r, "__lookupGetter__");
458    }
459
460    #[test]
461    fn denylist_lookup_setter_top_level() {
462        let val = json!({ "__lookupSetter__": "something" });
463        let r = validate_toolset_tool_args(&val);
464        assert_dangerous_key(&r, "__lookupSetter__");
465    }
466
467    // ── Denylist: each key nested in an object ────────────────────────────────
468
469    #[test]
470    fn denylist_tojson_nested_in_object() {
471        let val = json!({ "safe_key": { "toJSON": "evil" } });
472        let r = validate_toolset_tool_args(&val);
473        assert_dangerous_key(&r, "toJSON");
474    }
475
476    #[test]
477    fn denylist_then_nested_in_object() {
478        let val = json!({ "outer": { "inner": { "then": "evil" } } });
479        let r = validate_toolset_tool_args(&val);
480        assert_dangerous_key(&r, "then");
481    }
482
483    #[test]
484    fn denylist_proto_nested_in_object() {
485        let val = json!({ "metadata": { "__proto__": "evil" } });
486        let r = validate_toolset_tool_args(&val);
487        assert_dangerous_key(&r, "__proto__");
488    }
489
490    #[test]
491    fn denylist_constructor_nested_in_object() {
492        let val = json!({ "outer": { "constructor": "evil" } });
493        let r = validate_toolset_tool_args(&val);
494        assert_dangerous_key(&r, "constructor");
495    }
496
497    #[test]
498    fn denylist_prototype_nested_in_object() {
499        let val = json!({ "outer": { "prototype": "evil" } });
500        let r = validate_toolset_tool_args(&val);
501        assert_dangerous_key(&r, "prototype");
502    }
503
504    #[test]
505    fn denylist_tostring_nested_in_object() {
506        let val = json!({ "outer": { "toString": "evil" } });
507        let r = validate_toolset_tool_args(&val);
508        assert_dangerous_key(&r, "toString");
509    }
510
511    #[test]
512    fn denylist_valueof_nested_in_object() {
513        let val = json!({ "outer": { "valueOf": "evil" } });
514        let r = validate_toolset_tool_args(&val);
515        assert_dangerous_key(&r, "valueOf");
516    }
517
518    #[test]
519    fn denylist_define_getter_nested_in_object() {
520        let val = json!({ "outer": { "__defineGetter__": "evil" } });
521        let r = validate_toolset_tool_args(&val);
522        assert_dangerous_key(&r, "__defineGetter__");
523    }
524
525    #[test]
526    fn denylist_define_setter_nested_in_object() {
527        let val = json!({ "outer": { "__defineSetter__": "evil" } });
528        let r = validate_toolset_tool_args(&val);
529        assert_dangerous_key(&r, "__defineSetter__");
530    }
531
532    #[test]
533    fn denylist_lookup_getter_nested_in_object() {
534        let val = json!({ "outer": { "__lookupGetter__": "evil" } });
535        let r = validate_toolset_tool_args(&val);
536        assert_dangerous_key(&r, "__lookupGetter__");
537    }
538
539    #[test]
540    fn denylist_lookup_setter_nested_in_object() {
541        let val = json!({ "outer": { "__lookupSetter__": "evil" } });
542        let r = validate_toolset_tool_args(&val);
543        assert_dangerous_key(&r, "__lookupSetter__");
544    }
545
546    // ── Denylist: each key nested in an array ─────────────────────────────────
547    //
548    // Objects nested inside arrays ARE key-checked.
549
550    #[test]
551    fn denylist_tojson_nested_in_array() {
552        let val = json!([{ "toJSON": "evil" }]);
553        let r = validate_toolset_tool_args(&val);
554        assert_dangerous_key(&r, "toJSON");
555    }
556
557    #[test]
558    fn denylist_then_nested_in_array() {
559        let val = json!([{ "benign": "value" }, { "then": "evil" }]);
560        let r = validate_toolset_tool_args(&val);
561        assert_dangerous_key(&r, "then");
562    }
563
564    #[test]
565    fn denylist_proto_nested_in_array() {
566        let val = json!([{ "__proto__": "evil" }]);
567        let r = validate_toolset_tool_args(&val);
568        assert_dangerous_key(&r, "__proto__");
569    }
570
571    #[test]
572    fn denylist_constructor_nested_in_array() {
573        let val = json!([{ "constructor": "evil" }]);
574        let r = validate_toolset_tool_args(&val);
575        assert_dangerous_key(&r, "constructor");
576    }
577
578    #[test]
579    fn denylist_prototype_nested_in_array() {
580        let val = json!([{ "prototype": "evil" }]);
581        let r = validate_toolset_tool_args(&val);
582        assert_dangerous_key(&r, "prototype");
583    }
584
585    #[test]
586    fn denylist_tostring_nested_in_array() {
587        let val = json!([{ "toString": "evil" }]);
588        let r = validate_toolset_tool_args(&val);
589        assert_dangerous_key(&r, "toString");
590    }
591
592    #[test]
593    fn denylist_valueof_nested_in_array() {
594        let val = json!([{ "valueOf": "evil" }]);
595        let r = validate_toolset_tool_args(&val);
596        assert_dangerous_key(&r, "valueOf");
597    }
598
599    #[test]
600    fn denylist_define_getter_nested_in_array() {
601        let val = json!([{ "__defineGetter__": "evil" }]);
602        let r = validate_toolset_tool_args(&val);
603        assert_dangerous_key(&r, "__defineGetter__");
604    }
605
606    #[test]
607    fn denylist_define_setter_nested_in_array() {
608        let val = json!([{ "__defineSetter__": "evil" }]);
609        let r = validate_toolset_tool_args(&val);
610        assert_dangerous_key(&r, "__defineSetter__");
611    }
612
613    #[test]
614    fn denylist_lookup_getter_nested_in_array() {
615        let val = json!([{ "__lookupGetter__": "evil" }]);
616        let r = validate_toolset_tool_args(&val);
617        assert_dangerous_key(&r, "__lookupGetter__");
618    }
619
620    #[test]
621    fn denylist_lookup_setter_nested_in_array() {
622        let val = json!([{ "__lookupSetter__": "evil" }]);
623        let r = validate_toolset_tool_args(&val);
624        assert_dangerous_key(&r, "__lookupSetter__");
625    }
626
627    // ── Depth bound ───────────────────────────────────────────────────────────
628
629    #[test]
630    fn depth_at_max_plus_1_rejected() {
631        // Build a Value nested at TOOLSET_ARGS_MAX_DEPTH + 1.
632        let mut val = json!({ "leaf": "value" });
633        for _ in 0..=TOOLSET_ARGS_MAX_DEPTH {
634            val = json!({ "nested": val });
635        }
636        let r = validate_toolset_tool_args(&val);
637        assert!(
638            matches!(r, Err(ToolsetArgsError::NestingTooDeep { .. })),
639            "expected NestingTooDeep, got {r:?}"
640        );
641    }
642
643    /// Proof that the iterative walk does NOT stack-overflow at serde_json's
644    /// parse limit.  We construct a deeply-nested `Value` directly in memory
645    /// (bypassing the parse limit) and verify the walk returns `Err` without
646    /// overflowing.
647    ///
648    /// serde_json's parse recursion limit is typically ~128, but a
649    /// directly-constructed `Value` can be much deeper.  The iterative walk
650    /// must handle any in-memory depth without a stack overflow.
651    #[test]
652    fn depth_at_serde_parse_limit_no_overflow() {
653        // Construct a Value 500 levels deep (well past serde's ~128 parse limit).
654        // This can only be done in memory; serde_json would reject this as JSON text.
655        let depth = 500_usize;
656        let mut val = json!({ "leaf": "value" });
657        for _ in 0..depth {
658            val = json!({ "nested": val });
659        }
660        // Must return NestingTooDeep (not stack-overflow / panic).
661        let r = validate_toolset_tool_args(&val);
662        assert!(
663            matches!(r, Err(ToolsetArgsError::NestingTooDeep { .. })),
664            "expected NestingTooDeep for depth-{depth} value, got {r:?}"
665        );
666    }
667
668    // ── Unicode escape proof: decode-then-match catches escape evasion ────────
669    //
670    // serde_json decodes unicode escapes to the literal byte string during parsing.
671    // Our walk runs on the already-decoded `Value`, so the literal key is caught.
672
673    #[test]
674    fn proto_unicode_escaped_caught_as_literal() {
675        // Build the JSON string manually: __proto__ expressed as unicode escapes.
676        // serde_json::from_str will decode the escapes before our walk sees the key.
677        let json_str = r#"{ "__proto__": "evil" }"#;
678        let val: serde_json::Value = serde_json::from_str(json_str).unwrap();
679
680        // Verify the key is decoded to "__proto__" by serde_json.
681        if let serde_json::Value::Object(map) = &val {
682            assert!(
683                map.contains_key("__proto__"),
684                "serde_json must decode escape sequences to __proto__"
685            );
686        }
687
688        // The walk must catch it.
689        let r = validate_toolset_tool_args(&val);
690        assert_dangerous_key(&r, "__proto__");
691    }
692
693    // ── Redaction: secret in sibling field never appears in error ─────────────
694
695    #[test]
696    fn secret_in_sibling_value_not_in_error_display() {
697        // Plant a secret-shaped value in a sibling field alongside the dangerous key.
698        // The error Display must NOT contain the planted secret string.
699        let secret = "SBSECRETPLANTEDVALUETHATMUSTNEVERAPPEARINERROR12345ABCDEF";
700        let val = json!({
701            "account_id": secret,
702            "toJSON": "irrelevant_value"
703        });
704        let err = validate_toolset_tool_args(&val).unwrap_err();
705        let display = err.to_string();
706        assert!(
707            !display.contains(secret),
708            "error Display must not contain the planted secret: {display}"
709        );
710        // Also verify the dangerous key constant IS present.
711        assert!(
712            display.contains("toJSON"),
713            "error Display must mention the matched denylist constant: {display}"
714        );
715    }
716
717    // ── Error references matched &'static str constant, not input ─────────────
718
719    #[test]
720    fn error_names_denylist_constant_not_input() {
721        // A key that has the same BYTES as a denylist constant must produce
722        // an error whose matched_key is the &'static str from the denylist.
723        let val = json!({ "__proto__": "polluted" });
724        let err = validate_toolset_tool_args(&val).unwrap_err();
725        if let ToolsetArgsError::DangerousKey { matched_key } = &err {
726            // The matched_key must be one of the ARGS_KEY_DENYLIST constants.
727            assert!(
728                ARGS_KEY_DENYLIST.contains(matched_key),
729                "matched_key must be a denylist constant, got {matched_key:?}"
730            );
731            assert_eq!(*matched_key, "__proto__");
732        } else {
733            panic!("expected DangerousKey, got {err:?}");
734        }
735    }
736
737    // ── Mixed payloads ────────────────────────────────────────────────────────
738
739    #[test]
740    fn benign_key_before_dangerous_key_still_caught() {
741        // Safe keys before the dangerous one — the dangerous key must still be caught.
742        let val = json!({
743            "account_id": "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN",
744            "chain_id": "stellar:testnet",
745            "__proto__": "evil"
746        });
747        let r = validate_toolset_tool_args(&val);
748        assert!(
749            r.is_err(),
750            "dangerous key must be caught regardless of position"
751        );
752    }
753
754    #[test]
755    fn dangerous_key_in_object_inside_array_inside_object_caught() {
756        // Pathological nesting: object -> array -> object with dangerous key.
757        let val = json!({
758            "records": [
759                { "safe": "value" },
760                { "constructor": "pollution" }
761            ]
762        });
763        let r = validate_toolset_tool_args(&val);
764        assert_dangerous_key(&r, "constructor");
765    }
766
767    // ── Denylist completeness (count) ─────────────────────────────────────────
768
769    #[test]
770    fn denylist_has_exactly_11_entries() {
771        // Locks the denylist count so adding a new entry without updating the
772        // tests causes this to fail (complementary to the per-key tests above).
773        assert_eq!(
774            ARGS_KEY_DENYLIST.len(),
775            11,
776            "ARGS_KEY_DENYLIST must have exactly 11 entries"
777        );
778    }
779
780    // ── Node-count bound ──────────────────────────────────────────────────────
781
782    /// A wide flat object over TOOLSET_ARGS_MAX_NODES is rejected with TooManyNodes.
783    ///
784    /// This test exercises the O(width) case that TOOLSET_ARGS_MAX_DEPTH alone does
785    /// not prevent: a flat object passes depth-1 but can enqueue an arbitrary
786    /// number of entries onto the work-stack.
787    #[test]
788    fn wide_object_over_node_cap_rejected() {
789        // Build a flat object with TOOLSET_ARGS_MAX_NODES + 1 entries.
790        // Each entry is a benign (key, scalar) pair — no dangerous keys, no nesting.
791        let mut map = serde_json::Map::new();
792        for i in 0..=TOOLSET_ARGS_MAX_NODES {
793            map.insert(format!("field_{i}"), serde_json::Value::String("v".into()));
794        }
795        let val = serde_json::Value::Object(map);
796        let r = validate_toolset_tool_args(&val);
797        assert!(
798            matches!(r, Err(ToolsetArgsError::TooManyNodes { .. })),
799            "expected TooManyNodes for wide-over-cap object, got {r:?}"
800        );
801    }
802
803    /// A wide flat object UNDER TOOLSET_ARGS_MAX_NODES passes.
804    #[test]
805    fn wide_object_under_node_cap_passes() {
806        // Build a flat object with TOOLSET_ARGS_MAX_NODES / 2 entries — well under
807        // the cap and with no dangerous keys.
808        let half = TOOLSET_ARGS_MAX_NODES / 2;
809        let mut map = serde_json::Map::new();
810        for i in 0..half {
811            map.insert(format!("safe_{i}"), serde_json::Value::String("ok".into()));
812        }
813        let val = serde_json::Value::Object(map);
814        validate_toolset_tool_args(&val).unwrap();
815    }
816}