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}