diff --git a/aikido_zen/helpers/extract_strings_from_user_input.py b/aikido_zen/helpers/extract_strings_from_user_input.py index d092ba79..d9d08b46 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input.py +++ b/aikido_zen/helpers/extract_strings_from_user_input.py @@ -59,13 +59,17 @@ def extract_strings_from_user_input(obj, path_to_payload=None): results[obj] = build_path_to_payload(path_to_payload) jwt = try_decode_as_jwt(obj) if jwt[0]: + decoded = jwt[1] + # Do not add the issuer of the JWT as a string because it can contain a + # domain / url and produce false positives. + if is_mapping(decoded) and "iss" in decoded: + # Copy so we don't mutate the caller's object / cached parse result. + decoded = { + key: value for key, value in decoded.items() if key != "iss" + } for k, v in extract_strings_from_user_input( - jwt[1], path_to_payload + [{"type": "jwt"}] + decoded, path_to_payload + [{"type": "jwt"}] ).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 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 d80ad1fc..d93916b4 100644 --- a/aikido_zen/helpers/extract_strings_from_user_input_test.py +++ b/aikido_zen/helpers/extract_strings_from_user_input_test.py @@ -289,3 +289,56 @@ def test_extract_strings_from_user_input_cached_multiple_sources(mock_context): # To run the tests, use the command: pytest .py + + +def test_jwt_payload_spoofing_does_not_hide_malicious_value(): + """ + Regression test for AIKIDO-9E72UAVT (Detection Bypass via JWT Payload Spoofing). + + A JWT whose decoded body contains a ".iss" key with the same value as a + real malicious payload must NOT cause the malicious payload to be dropped from + the extracted strings. Previously the `v.endswith(".iss")` heuristic + + dict-key overwriting caused the path-traversal payload to be silently skipped. + + Decoded body: + {"filename": "../../../../etc/passwd", ".iss": "../../../../etc/passwd"} + """ + # base64url(json.dumps({"filename": "../../../../etc/passwd", + # ".iss": "../../../../etc/passwd"})) + jwt = ( + "header." + "eyJmaWxlbmFtZSI6ICIuLi8uLi8uLi8uLi9ldGMvcGFzc3dkIiwgIjxqd3Q-" + "LmlzcyI6ICIuLi8uLi8uLi8uLi9ldGMvcGFzc3dkIn0=" + ".signature" + ) + result = extract_strings_from_user_input({"cookie": jwt}) + assert "../../../../etc/passwd" in result + + +def test_jwt_iss_claim_is_still_ignored(): + """The legitimate top-level `iss` claim must still be ignored (no false positives).""" + # base64url(json.dumps({"iss": "https://example.com/../secret"})) + jwt = ( + "header." + "eyJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS8uLi9zZWNyZXQifQ==" + ".signature" + ) + result = extract_strings_from_user_input({"cookie": jwt}) + assert "https://example.com/../secret" not in result + + +def test_jwt_iss_spoof_key_does_not_hide_malicious_value(): + """ + A value placed under a real "iss" claim is ignored, but a *different* + malicious value elsewhere must still be extracted. + """ + # base64url(json.dumps({"iss": "https://example.com", + # "cmd": "; cat /etc/passwd"})) + jwt = ( + "header." + "eyJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbSIsICJjbWQiOiAiOyBjYXQgL2V0Yy9wYXNzd2QifQ==" + ".signature" + ) + result = extract_strings_from_user_input({"cookie": jwt}) + assert "; cat /etc/passwd" in result + assert "https://example.com" not in result