From a03ef19f0e58a9d14fee0b871e3459aa7bcbc6fb Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Wed, 23 Sep 2026 15:17:03 +0300 Subject: [PATCH 1/5] Cap nesting depth when extracting strings from user input --- .../extract_strings_from_user_input.py | 52 ++++++++--- .../extract_strings_from_user_input_test.py | 88 +++++++++++++++++++ 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index d092ba798..306344c00 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -7,6 +7,11 @@ from aikido_zen.helpers.build_path_to_payload import build_path_to_payload import aikido_zen.context as ctx +# A RecursionError during extraction aborts the whole scan and the request goes +# through unchecked. Each nesting level costs one stack frame; Python allows 1000 by +# default and the web framework already uses part of them, so stop well below that. +MAX_TRAVERSAL_DEPTH = 30 + def extract_strings_from_user_input_cached(obj, source): """Use the cache to speed up getting user input""" @@ -32,40 +37,65 @@ def extract_strings_from_user_input(obj, path_to_payload=None): if path_to_payload is None: path_to_payload = [] + results, _ = extract_strings_and_nesting(obj, path_to_payload) + return results + + +def extract_strings_and_nesting(obj, path_to_payload): + """ + Extracts strings from an object and returns how deep its containers nest, + so str(obj) can be skipped where it would recurse too far + """ results = {} + # Path length is the depth; nothing past the limit is walked, so it is too deep. + if len(path_to_payload) >= MAX_TRAVERSAL_DEPTH: + return results, MAX_TRAVERSAL_DEPTH + 1 + + nesting = 0 + if is_mapping(obj): # Stringifying the dict and adding it as user input is resource intensive # And in most cases shouldn't be necessary. for key, value in obj.items(): results[key] = build_path_to_payload(path_to_payload) - for k, v in extract_strings_from_user_input( + child_results, child_nesting = extract_strings_and_nesting( value, path_to_payload + [{"type": "object", "key": key}] - ).items(): + ) + for k, v in child_results.items(): results[k] = v + nesting = max(nesting, child_nesting + 1) if isinstance(obj, (set, list, tuple)): - # Add the stringified array as well to the results, there might - # be accidental concatenation if the client expects a string but gets the array - # E.g. HTTP Parameter pollution - results[str(obj)] = build_path_to_payload(path_to_payload) for i, value in enumerate(obj): - for k, v in extract_strings_from_user_input( + child_results, child_nesting = extract_strings_and_nesting( value, path_to_payload + [{"type": "array", "index": i}] - ).items(): + ) + for k, v in child_results.items(): results[k] = v + nesting = max(nesting, child_nesting + 1) + + # Add the stringified array as well to the results, there might + # be accidental concatenation if the client expects a string but gets the array + # E.g. HTTP Parameter pollution + # str() recurses through the whole array and ignores the traversal limit, so + # arrays nested deeper than the limit are skipped to avoid a RecursionError. + if nesting <= MAX_TRAVERSAL_DEPTH: + results[str(obj)] = build_path_to_payload(path_to_payload) if isinstance(obj, str): results[obj] = build_path_to_payload(path_to_payload) jwt = try_decode_as_jwt(obj) if jwt[0]: - for k, v in extract_strings_from_user_input( + # A JWT payload does not add nesting: str() never decodes a string. + child_results, _ = extract_strings_and_nesting( jwt[1], path_to_payload + [{"type": "jwt"}] - ).items(): + ) + for k, v in child_results.items(): if k == "iss" or v.endswith(".iss"): # Do not add the issuer of the JWT as a string because it can contain a # domain / url and produce false positives continue results[k] = v - return results + return results, nesting diff --git a/aikido_zen/helpers/extract_strings_from_user_input_test.py b/aikido_zen/helpers/extract_strings_from_user_input_test.py index d80ad1fc4..663a4c7f9 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input_test.py +++ b/aikido_zen/helpers/extract_strings_from_user_input_test.py @@ -1,6 +1,8 @@ +import base64 import pytest from unittest.mock import MagicMock, patch from aikido_zen.helpers.extract_strings_from_user_input import ( + MAX_TRAVERSAL_DEPTH, extract_strings_from_user_input, extract_strings_from_user_input_cached, ) @@ -288,4 +290,90 @@ def test_extract_strings_from_user_input_cached_multiple_sources(mock_context): assert mock_context.parsed_userinput["source2"] == result2 +def nested_list(depth, leaf="asd"): + current = [leaf] + for _ in range(depth): + current = [current] + return current + + +def nested_dict(depth, leaf="asd"): + current = leaf + for _ in range(depth): + current = {"a": current} + return current + + +def fake_jwt(payload_json): + payload = base64.urlsafe_b64encode(payload_json.encode()).rstrip(b"=").decode() + return f".{payload}." + + +def test_extracts_strings_up_to_max_depth_only(): + assert extract_strings_from_user_input( + nested_dict(MAX_TRAVERSAL_DEPTH - 1, "leaf") + ) == from_obj( + { + "a": ".a" * (MAX_TRAVERSAL_DEPTH - 2), + "leaf": ".a" * (MAX_TRAVERSAL_DEPTH - 1), + } + ) + assert extract_strings_from_user_input( + nested_dict(MAX_TRAVERSAL_DEPTH, "leaf") + ) == from_obj({"a": ".a" * (MAX_TRAVERSAL_DEPTH - 1)}) + + +def test_stringifies_arrays_unless_nested_deeper_than_max_depth(): + assert extract_strings_from_user_input({"arr": [["p"], {"k": ["q"]}]}) == from_obj( + { + "arr": ".", + "[['p'], {'k': ['q']}]": ".arr", + "['p']": ".arr.[0]", + "p": ".arr.[0].[0]", + "k": ".arr.[1]", + "['q']": ".arr.[1].k", + "q": ".arr.[1].k.[0]", + } + ) + assert extract_strings_from_user_input( + {"arr": nested_list(MAX_TRAVERSAL_DEPTH + 1)} + ) == from_obj({"arr": "."}) + + +def test_deeply_nested_list_keeps_other_strings(): + # deep enough that any recursion through the value overflows the stack + assert extract_strings_from_user_input( + {"deep": nested_list(100_000), "user_input": "/etc/passwd"} + ) == from_obj({"deep": ".", "user_input": ".", "/etc/passwd": ".user_input"}) + + +def test_deeply_nested_mapping_keeps_other_strings(): + assert extract_strings_from_user_input( + {"deep": nested_dict(5000), "user_input": "/etc/passwd"} + ) == from_obj( + { + "deep": ".", + "a": ".deep" + ".a" * (MAX_TRAVERSAL_DEPTH - 2), + "user_input": ".", + "/etc/passwd": ".user_input", + } + ) + + +def test_deeply_nested_jwt_payload_keeps_other_strings(): + depth = 100_000 + jwt = fake_jwt("[" * depth + '"asd"' + "]" * depth) + + assert extract_strings_from_user_input( + {"user_input": "/etc/passwd", "not_used": jwt} + ) == from_obj( + { + "user_input": ".", + "/etc/passwd": ".user_input", + "not_used": ".", + jwt: ".not_used", + } + ) + + # To run the tests, use the command: pytest .py From 561cb055a4edcd217671eae302e2fc82d676ddc0 Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Wed, 23 Sep 2026 15:40:38 +0300 Subject: [PATCH 2/5] Cap nesting depth improved comments --- .../extract_strings_from_user_input.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index 306344c00..0fdcbf3b1 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -7,9 +7,7 @@ from aikido_zen.helpers.build_path_to_payload import build_path_to_payload import aikido_zen.context as ctx -# A RecursionError during extraction aborts the whole scan and the request goes -# through unchecked. Each nesting level costs one stack frame; Python allows 1000 by -# default and the web framework already uses part of them, so stop well below that. +# Past this depth a RecursionError would abort the scan and let the request through. MAX_TRAVERSAL_DEPTH = 30 @@ -42,13 +40,11 @@ def extract_strings_from_user_input(obj, path_to_payload=None): def extract_strings_and_nesting(obj, path_to_payload): - """ - Extracts strings from an object and returns how deep its containers nest, - so str(obj) can be skipped where it would recurse too far - """ + """Extracts strings from an object and returns how deep its containers nest""" results = {} - # Path length is the depth; nothing past the limit is walked, so it is too deep. + # path_to_payload has one entry per level, so its length tells how deep we are. + # Nothing past the limit is walked, so report it as too deep. if len(path_to_payload) >= MAX_TRAVERSAL_DEPTH: return results, MAX_TRAVERSAL_DEPTH + 1 @@ -67,6 +63,9 @@ def extract_strings_and_nesting(obj, path_to_payload): nesting = max(nesting, child_nesting + 1) if isinstance(obj, (set, list, tuple)): + # Add the stringified array as well to the results, there might + # be accidental concatenation if the client expects a string but gets the array + # E.g. HTTP Parameter pollution for i, value in enumerate(obj): child_results, child_nesting = extract_strings_and_nesting( value, path_to_payload + [{"type": "array", "index": i}] @@ -75,11 +74,8 @@ def extract_strings_and_nesting(obj, path_to_payload): results[k] = v nesting = max(nesting, child_nesting + 1) - # Add the stringified array as well to the results, there might - # be accidental concatenation if the client expects a string but gets the array - # E.g. HTTP Parameter pollution - # str() recurses through the whole array and ignores the traversal limit, so - # arrays nested deeper than the limit are skipped to avoid a RecursionError. + # We track how deep the children nest because str() walks the whole array by + # itself, ignoring our limit; too deep an array would raise a RecursionError. if nesting <= MAX_TRAVERSAL_DEPTH: results[str(obj)] = build_path_to_payload(path_to_payload) From 567a90bf3605cd2c263b073ac1d91be56aa7eb31 Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Wed, 23 Sep 2026 15:51:05 +0300 Subject: [PATCH 3/5] Improved comments --- aikido_zen/helpers/extract_strings_from_user_input.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index 0fdcbf3b1..b316edacf 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -7,7 +7,7 @@ from aikido_zen.helpers.build_path_to_payload import build_path_to_payload import aikido_zen.context as ctx -# Past this depth a RecursionError would abort the scan and let the request through. +# Deeper input would overflow the stack and let the request through unchecked. MAX_TRAVERSAL_DEPTH = 30 @@ -43,8 +43,7 @@ def extract_strings_and_nesting(obj, path_to_payload): """Extracts strings from an object and returns how deep its containers nest""" results = {} - # path_to_payload has one entry per level, so its length tells how deep we are. - # Nothing past the limit is walked, so report it as too deep. + # Length tells how deep we are, nothing past the limit is walked if len(path_to_payload) >= MAX_TRAVERSAL_DEPTH: return results, MAX_TRAVERSAL_DEPTH + 1 @@ -83,7 +82,6 @@ def extract_strings_and_nesting(obj, path_to_payload): results[obj] = build_path_to_payload(path_to_payload) jwt = try_decode_as_jwt(obj) if jwt[0]: - # A JWT payload does not add nesting: str() never decodes a string. child_results, _ = extract_strings_and_nesting( jwt[1], path_to_payload + [{"type": "jwt"}] ) From 2c1dc580dfc001f8ba5dddb7ab285bd28bcb59f1 Mon Sep 17 00:00:00 2001 From: iacobdaniel Date: Wed, 23 Sep 2026 16:43:59 +0300 Subject: [PATCH 4/5] Update aikido_zen/helpers/extract_strings_from_user_input.py Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- aikido_zen/helpers/extract_strings_from_user_input.py | 1 - 1 file changed, 1 deletion(-) diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index b316edacf..e53b64349 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -43,7 +43,6 @@ def extract_strings_and_nesting(obj, path_to_payload): """Extracts strings from an object and returns how deep its containers nest""" results = {} - # Length tells how deep we are, nothing past the limit is walked if len(path_to_payload) >= MAX_TRAVERSAL_DEPTH: return results, MAX_TRAVERSAL_DEPTH + 1 From 9db4589113c6e80c0b39cd33e20a129051e8398a Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Thu, 24 Sep 2026 17:42:15 +0300 Subject: [PATCH 5/5] Changed max depth limit. --- aikido_zen/helpers/extract_strings_from_user_input.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index b316edacf..e8a64a3fb 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -8,7 +8,7 @@ import aikido_zen.context as ctx # Deeper input would overflow the stack and let the request through unchecked. -MAX_TRAVERSAL_DEPTH = 30 +MAX_TRAVERSAL_DEPTH = 64 def extract_strings_from_user_input_cached(obj, source): @@ -73,8 +73,6 @@ def extract_strings_and_nesting(obj, path_to_payload): results[k] = v nesting = max(nesting, child_nesting + 1) - # We track how deep the children nest because str() walks the whole array by - # itself, ignoring our limit; too deep an array would raise a RecursionError. if nesting <= MAX_TRAVERSAL_DEPTH: results[str(obj)] = build_path_to_payload(path_to_payload)