test(clear_signing): recursion depth tests.
What changed, and why it matters
This commit only adds new unit tests for the Ethereum clear-signing feature. It checks that deeply nested data structures are rejected and that shallow-but-wide structures are accepted. No production code is changed, so it cannot by itself introduce or fix a security bug. It does confirm that two existing limits—`_MAX_NESTED_ARRAYS` (2) and `_MAX_ABI_NESTING` (8)—are enforced by the parser.
No action required. Treat as routine test coverage. If reviewing for security, verify that the production parser constants `_MAX_NESTED_ARRAYS` and `_MAX_ABI_NESTING` are actually defined and enforced in the implementation, since the tests assume but do not change them.
Security signals we found
Existing parser enforces a hard cap on nested arrays (`_MAX_NESTED_ARRAYS = 2`) to bound parse work.
Existing parser enforces a hard cap on ABI recursion depth (`_MAX_ABI_NESTING = 8`) to bound call-stack depth.
Tests reference attacker-controlled calldata element counts as the reason for the array cap.
No patch to production logic; tests document and lock in current defensive behavior.
Evidence from the diff
The diff is a test-only change in core/tests/test_apps.ethereum.clear_signing.py. It adds tests verifying that ABIValue.from_proto raises InvalidFormatDefinition for three nested arrays, for three nested arrays reached through a tuple, for tuple nesting deeper than _MAX_ABI_NESTING (8), and for the OKX smartSwapByOrderId batches shape. It also adds a positive test that sibling arrays do not accumulate toward the nesting cap. No implementation code is modified.
Changed components
core/tests/test_apps.ethereum.clear_signing.pyInspect captured patch +98 / −8
### core/tests/test_apps.ethereum.clear_signing.py
@@ -1861,6 +1861,104 @@ def test_tuple_field_may_be_an_array(self):
self.assertEqual(parsed, ([7, 8],))
self.assertEqual(consumed, 32)
+ # --- nesting caps ---
+ #
+ # Two caps, bounding two different resources. `_MAX_NESTED_ARRAYS` (2)
+ # bounds parse *work*: a tuple parses each field once so it adds, an array
+ # parses its element subtree once per element so it multiplies, and the
+ # element count comes from attacker-supplied calldata. `_MAX_ABI_NESTING`
+ # (8) bounds the *recursion* itself, which is as deep as the tree whatever
+ # the node types. Both values are hardcoded below - they are `const()` with
+ # a leading underscore, so MicroPython keeps them compile-time only and
+ # they cannot be imported.
+
+ def test_three_nested_arrays_rejected(self):
+ # Rejected by `_MAX_NESTED_ARRAYS`: each nested array level multiplies
+ # the parse work by an attacker-controlled element count, so the cap is
+ # what keeps the worst case at the order already reachable today.
+ node = p_array(p_array(p_array(p_atomic(EABIT.ABI_UINT256))))
+ with self.assertRaises(InvalidFormatDefinition):
+ ABIValue.from_proto(node)
+
+ def test_three_nested_arrays_through_tuple_rejected(self):
+ # The array counter follows the root-to-leaf path, not the top level:
+ # burying the same three array levels under a tuple does not launder
+ # them past the cap.
+ node = p_tuple(
+ [p_array(p_array(p_array(p_atomic(EABIT.ABI_UINT256))))],
+ is_dynamic=True,
+ )
+ with self.assertRaises(InvalidFormatDefinition):
+ ABIValue.from_proto(node)
+
+ def test_sibling_arrays_do_not_accumulate(self):
+ # (uint256[] a, uint256[] b): two arrays in one tuple sit on separate
+ # paths, so each carries one array level, not two between them. Sibling
+ # arrays add work; only nesting multiplies it.
+ node = ABIValue.from_proto(
+ p_tuple(
+ [
+ p_array(p_atomic(EABIT.ABI_UINT256)),
+ p_array(p_atomic(EABIT.ABI_UINT256)),
+ ],
+ is_dynamic=True,
+ )
+ )
+
+ data = memoryview(
+ to_bytes(32) # 0 outer head -> struct body at 32
+ + to_bytes(64) # 32 field 0 head, rel to 32 -> array a at 96
+ + to_bytes(160) # 64 field 1 head, rel to 32 -> array b at 192
+ + to_bytes(2) # 96 a: count
+ + to_bytes(1) # 128
+ + to_bytes(2) # 160
+ + to_bytes(1) # 192 b: count
+ + to_bytes(9) # 224
+ )
+ parsed, consumed = node.parse(data, 0)
+
+ self.assertEqual(parsed, ([1, 2], [9]))
+ self.assertEqual(consumed, 32)
+
+ def test_depth_cap_boundary(self):
+ # `_MAX_ABI_NESTING` is 8 and the check is `depth > cap` with the root
+ # at depth 0, so 8 wrapping tuples (leaf at depth 8) is the deepest
+ # accepted and 9 is one too many. Pure tuple nesting costs no parse
+ # work at all - one value per field - which is exactly why the array
+ # cap cannot stand in for this one.
+ def nest(n):
+ node = p_atomic(EABIT.ABI_UINT256)
+ for _ in range(n):
+ node = p_tuple([node], is_dynamic=False)
+ return node
+
+ deepest_accepted = ABIValue.from_proto(nest(8))
+ self.assertFalse(deepest_accepted.is_dynamic)
+ self.assertEqual(deepest_accepted.head_size, 32)
+
+ with self.assertRaises(InvalidFormatDefinition):
+ ABIValue.from_proto(nest(9))
+
+ def test_okx_smart_swap_shape_rejected(self):
+ # okx `smartSwapByOrderId`s `batches` parameter:
+ # (address[], address[], uint256[], bytes[], uint256)[][]
+ # Three array levels - the two on the parameter plus the one inside the
+ # element struct - so it is over the cap and the whole display format
+ # is dropped. This is the deferred case: it flips to accepted once
+ # unreferenced parameters are pruned, since no field ever reads it.
+ element = p_tuple(
+ [
+ p_array(p_atomic(EABIT.ABI_ADDRESS)), # mixAdapters
+ p_array(p_atomic(EABIT.ABI_ADDRESS)), # assetTo
+ p_array(p_atomic(EABIT.ABI_UINT256)), # rawData
+ p_array(p_dynamic(EABIT.ABI_BYTES)), # extraData
+ p_atomic(EABIT.ABI_UINT256), # fromToken
+ ],
+ is_dynamic=True,
+ )
+ with self.assertRaises(InvalidFormatDefinition):
+ ABIValue.from_proto(p_array(p_array(element)))
+
# --- shapes the decoder refuses ---
def test_no_variant_set_rejected(self):
@@ -1872,14 +1970,6 @@ def test_unknown_abi_type_rejected(self):
with self.assertRaises(InvalidFormatDefinition):
ABIValue.from_proto(p_atomic(99))
- def test_three_nested_arrays_rejected(self):
- # Rejected by `_MAX_NESTED_ARRAYS`: each nested array level multiplies
- # the parse work by an attacker-controlled element count, so the cap is
- # what keeps the worst case at the order already reachable today.
- node = p_array(p_array(p_array(p_atomic(EABIT.ABI_UINT256))))
- with self.assertRaises(InvalidFormatDefinition):
- ABIValue.from_proto(node)
-
if __name__ == "__main__":
unittest.main()Why this scored 12/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.