From fed00a1aea06e1e9423906e988876bb09b8380e9 Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Tue, 27 Oct 2026 13:25:24 +0100 Subject: [PATCH 1/4] Parse request cookies without SimpleCookie --- aikido_zen/context/asgi/init_test.py | 23 +++++++++++++++++++++++ aikido_zen/context/parse_cookies.py | 22 +++++++++++----------- aikido_zen/context/parse_cookies_test.py | 22 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/aikido_zen/context/asgi/init_test.py b/aikido_zen/context/asgi/init_test.py index 53b54bd85..c53911ec4 100644 --- a/aikido_zen/context/asgi/init_test.py +++ b/aikido_zen/context/asgi/init_test.py @@ -1,6 +1,10 @@ import pytest from urllib.parse import parse_qs +from aikido_zen.context import Context as ZenContext from aikido_zen.context.asgi import set_asgi_attributes_on_context +from aikido_zen.vulnerabilities.sql_injection.context_contains_sql_injection import ( + context_contains_sql_injection, +) class Context: @@ -180,6 +184,25 @@ def test_asgi_scope_invalid_utf8(): assert context5.url == "http://192.168.0.5:80/search" +def test_sql_injection_in_a_cookie_next_to_a_rejected_cookie_is_detected(): + payload = "1' OR '1'='1" + scope = { + "type": "http", + "method": "GET", + "headers": [(b"cookie", f"id={payload}; ,2=2".encode())], + "query_string": b"", + "client": ["6.6.6.6"], + "server": ["192.168.0.6", 80], + "scheme": "http", + "root_path": "", + "path": "/users", + } + context = ZenContext(req=scope, source="starlette") + query = f"SELECT * FROM users WHERE id = '{payload}'" + assert context.cookies == {"id": payload, ",2": "2"} + assert context_contains_sql_injection(query, "select", context, "sqlite") + + def test_valid_utf8_query_matches_strict_decode(): raw = "q=café' OR 1=1 --&b=d".encode("utf-8") scope = { diff --git a/aikido_zen/context/parse_cookies.py b/aikido_zen/context/parse_cookies.py index ddc729ca1..b355da5a3 100644 --- a/aikido_zen/context/parse_cookies.py +++ b/aikido_zen/context/parse_cookies.py @@ -2,20 +2,20 @@ Exports `parse_cookies` function which parses WSGI cookies """ -from http.cookies import SimpleCookie, CookieError +from http.cookies import _unquote def parse_cookies(cookie_str): """Parse cookie string from headers""" + # Unlike SimpleCookie, which stops at a value with a space, a comma or JSON, + # this keeps every cookie, so none is hidden from the scan. cookie_dict = {} - cookies = SimpleCookie() - try: - cookies.load(cookie_str) - except CookieError: - # Invalid keys can cause errors, we want to ignore them - pass - - for key, morsel in cookies.items(): - cookie_dict[key] = morsel.value - + for chunk in cookie_str.split(";"): + if "=" in chunk: + key, val = chunk.split("=", 1) + else: + key, val = "", chunk + key, val = key.strip(), val.strip() + if key or val: + cookie_dict[key] = _unquote(val) return cookie_dict diff --git a/aikido_zen/context/parse_cookies_test.py b/aikido_zen/context/parse_cookies_test.py index 72008c48e..c0ebd4168 100644 --- a/aikido_zen/context/parse_cookies_test.py +++ b/aikido_zen/context/parse_cookies_test.py @@ -24,3 +24,25 @@ def test_parse_cookies_with_special_characters(): cookie_str = "sessionid=abc123; user=John@Doe; theme=dark&light" expected = {"sessionid": "abc123", "user": "John@Doe", "theme": "dark&light"} assert parse_cookies(cookie_str) == expected + + +@pytest.mark.parametrize( + "cookie_str, expected", + [ + ( + 'user={"username":{"$regex":".*"}}; ,2=2', + {"user": '{"username":{"$regex":".*"}}', ",2": "2"}, + ), + ('user={"a":1}', {"user": '{"a":1}'}), + ("id=1' OR '1'='1", {"id": "1' OR '1'='1"}), + (",2=2; id=abc", {",2": "2", "id": "abc"}), + ('user={"a":1}; sid=abc', {"user": '{"a":1}', "sid": "abc"}), + ( + 'sid=abc; user={"a":1}; theme=dark', + {"sid": "abc", "user": '{"a":1}', "theme": "dark"}, + ), + ("novalue", {"": "novalue"}), + ], +) +def test_parse_cookies_keeps_cookies_simplecookie_rejects(cookie_str, expected): + assert parse_cookies(cookie_str) == expected From ea70543a3e1116b459316b259fc5adf57a7d3328 Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Tue, 27 Oct 2026 13:52:00 +0100 Subject: [PATCH 2/4] Read every Cookie header, not only the last one --- aikido_zen/context/asgi/__init__.py | 10 ++++------ aikido_zen/context/asgi/init_test.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/aikido_zen/context/asgi/__init__.py b/aikido_zen/context/asgi/__init__.py index fa59fe6d2..dad18ffbf 100644 --- a/aikido_zen/context/asgi/__init__.py +++ b/aikido_zen/context/asgi/__init__.py @@ -17,12 +17,10 @@ def set_asgi_attributes_on_context(context, scope): context.method = scope["method"] context.headers = normalize_asgi_headers(scope["headers"]) - if context.headers.get_header("COOKIE"): - # Right now just use the first Cookie header, will change later to use - # framework definition for cookies. - context.cookies = parse_cookies(context.headers.get_header("COOKIE")) - else: - context.cookies = {} + # A client can send several Cookie headers, and Starlette merges them all. + context.cookies = {} + for cookie_header in context.headers.get("COOKIE", []): + context.cookies.update(parse_cookies(cookie_header)) context.url = build_url_from_asgi(scope) context.query = parse_qs(scope["query_string"].decode("utf-8", errors="replace")) diff --git a/aikido_zen/context/asgi/init_test.py b/aikido_zen/context/asgi/init_test.py index c53911ec4..8ed80bd8f 100644 --- a/aikido_zen/context/asgi/init_test.py +++ b/aikido_zen/context/asgi/init_test.py @@ -203,6 +203,28 @@ def test_sql_injection_in_a_cookie_next_to_a_rejected_cookie_is_detected(): assert context_contains_sql_injection(query, "select", context, "sqlite") +def test_sql_injection_in_an_earlier_cookie_header_is_detected(): + payload = "1' OR '1'='1" + scope = { + "type": "http", + "method": "GET", + "headers": [ + (b"cookie", f"id={payload}".encode()), + (b"cookie", b"sid=abc"), + ], + "query_string": b"", + "client": ["6.6.6.6"], + "server": ["192.168.0.6", 80], + "scheme": "http", + "root_path": "", + "path": "/users", + } + context = ZenContext(req=scope, source="starlette") + query = f"SELECT * FROM users WHERE id = '{payload}'" + assert context.cookies == {"id": payload, "sid": "abc"} + assert context_contains_sql_injection(query, "select", context, "sqlite") + + def test_valid_utf8_query_matches_strict_decode(): raw = "q=café' OR 1=1 --&b=d".encode("utf-8") scope = { From f5a7c97962bd5b6c3905b682dc643a63f25d80a6 Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Tue, 27 Oct 2026 18:58:09 +0100 Subject: [PATCH 3/4] Keep every value of a cookie name that appears more than once --- aikido_zen/context/asgi/__init__.py | 6 ++-- aikido_zen/context/asgi/init_test.py | 33 ++++++++++++++++++++ aikido_zen/context/parse_cookies.py | 38 ++++++++++++++---------- aikido_zen/context/parse_cookies_test.py | 16 ++++++++++ 4 files changed, 74 insertions(+), 19 deletions(-) diff --git a/aikido_zen/context/asgi/__init__.py b/aikido_zen/context/asgi/__init__.py index dad18ffbf..71f047a4b 100644 --- a/aikido_zen/context/asgi/__init__.py +++ b/aikido_zen/context/asgi/__init__.py @@ -17,10 +17,8 @@ def set_asgi_attributes_on_context(context, scope): context.method = scope["method"] context.headers = normalize_asgi_headers(scope["headers"]) - # A client can send several Cookie headers, and Starlette merges them all. - context.cookies = {} - for cookie_header in context.headers.get("COOKIE", []): - context.cookies.update(parse_cookies(cookie_header)) + # Parse every Cookie header in both its decoded readings, so no value is missed. + context.cookies = parse_cookies(*context.headers.get("COOKIE", [])) context.url = build_url_from_asgi(scope) context.query = parse_qs(scope["query_string"].decode("utf-8", errors="replace")) diff --git a/aikido_zen/context/asgi/init_test.py b/aikido_zen/context/asgi/init_test.py index 8ed80bd8f..d4af745ae 100644 --- a/aikido_zen/context/asgi/init_test.py +++ b/aikido_zen/context/asgi/init_test.py @@ -225,6 +225,39 @@ def test_sql_injection_in_an_earlier_cookie_header_is_detected(): assert context_contains_sql_injection(query, "select", context, "sqlite") +@pytest.mark.parametrize( + "cookie_headers, app_value", + [ + ([b"id=1' OR '1'='1; id=1"], "1' OR '1'='1"), + ([b"id=1; id=1' OR '1'='1"], "1' OR '1'='1"), + ([b"id=1' OR '1'='1", b"id=1"], "1' OR '1'='1"), + ([b"id=1", b"id=1' OR '1'='1"], "1' OR '1'='1"), + (["id=café' OR '1'='1".encode()], "café' OR '1'='1"), + (["id=café' OR '1'='1".encode()], "café' OR '1'='1"), + ], +) +def test_sql_injection_in_any_cookie_value_the_app_can_read_is_detected( + cookie_headers, app_value +): + scope = { + "type": "http", + "method": "GET", + "headers": [(b"cookie", value) for value in cookie_headers], + "query_string": b"", + "client": ["6.6.6.6"], + "server": ["192.168.0.6", 80], + "scheme": "http", + "root_path": "", + "path": "/users", + } + context = ZenContext(req=scope, source="starlette") + query = f"SELECT * FROM users WHERE id = '{app_value}'" + assert context_contains_sql_injection(query, "select", context, "sqlite") + assert not context_contains_sql_injection( + "SELECT * FROM users WHERE id = '1'", "select", context, "sqlite" + ) + + def test_valid_utf8_query_matches_strict_decode(): raw = "q=café' OR 1=1 --&b=d".encode("utf-8") scope = { diff --git a/aikido_zen/context/parse_cookies.py b/aikido_zen/context/parse_cookies.py index b355da5a3..bbc5a2d9c 100644 --- a/aikido_zen/context/parse_cookies.py +++ b/aikido_zen/context/parse_cookies.py @@ -1,21 +1,29 @@ """ -Exports `parse_cookies` function which parses WSGI cookies +Exports `parse_cookies` function which parses request cookies """ from http.cookies import _unquote -def parse_cookies(cookie_str): - """Parse cookie string from headers""" - # Unlike SimpleCookie, which stops at a value with a space, a comma or JSON, - # this keeps every cookie, so none is hidden from the scan. - cookie_dict = {} - for chunk in cookie_str.split(";"): - if "=" in chunk: - key, val = chunk.split("=", 1) - else: - key, val = "", chunk - key, val = key.strip(), val.strip() - if key or val: - cookie_dict[key] = _unquote(val) - return cookie_dict +def parse_cookies(*cookie_strs): + """Parse cookie strings from headers""" + # Unlike SimpleCookie, this keeps every cookie, even one with a space, a comma or JSON, + # and every value of a repeated name, because the app may read any of them. + cookies = {} + for cookie_str in cookie_strs: + for chunk in cookie_str.split(";"): + if "=" in chunk: + key, val = chunk.split("=", 1) + else: + key, val = "", chunk + key, val = key.strip(), _unquote(val.strip()) + if not key and not val: + continue + if key not in cookies: + cookies[key] = val + elif isinstance(cookies[key], list): + if val not in cookies[key]: + cookies[key].append(val) + elif cookies[key] != val: + cookies[key] = [cookies[key], val] + return cookies diff --git a/aikido_zen/context/parse_cookies_test.py b/aikido_zen/context/parse_cookies_test.py index c0ebd4168..8da70670b 100644 --- a/aikido_zen/context/parse_cookies_test.py +++ b/aikido_zen/context/parse_cookies_test.py @@ -46,3 +46,19 @@ def test_parse_cookies_with_special_characters(): ) def test_parse_cookies_keeps_cookies_simplecookie_rejects(cookie_str, expected): assert parse_cookies(cookie_str) == expected + + +@pytest.mark.parametrize( + "cookie_strs, expected", + [ + (["id=a; id=b"], {"id": ["a", "b"]}), + (["id=a", "id=b"], {"id": ["a", "b"]}), + (["id=a; sid=1", "id=a"], {"id": "a", "sid": "1"}), + (['id="a b"'], {"id": "a b"}), + (['id="a\\"b"'], {"id": 'a"b'}), + (["name=café"], {"name": "café"}), + (["café=1"], {"café": "1"}), + ], +) +def test_parse_cookies_keeps_every_value(cookie_strs, expected): + assert parse_cookies(*cookie_strs) == expected From e991b45553ccc99e90402f8bb51019013b87576f Mon Sep 17 00:00:00 2001 From: Daniel Iulian Iacob Date: Tue, 27 Oct 2026 19:42:54 +0100 Subject: [PATCH 4/4] Test cookies without a name=value pair --- aikido_zen/context/parse_cookies_test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/aikido_zen/context/parse_cookies_test.py b/aikido_zen/context/parse_cookies_test.py index 8da70670b..dd0533bbf 100644 --- a/aikido_zen/context/parse_cookies_test.py +++ b/aikido_zen/context/parse_cookies_test.py @@ -58,6 +58,8 @@ def test_parse_cookies_keeps_cookies_simplecookie_rejects(cookie_str, expected): (['id="a\\"b"'], {"id": 'a"b'}), (["name=café"], {"name": "café"}), (["café=1"], {"café": "1"}), + (["a; b"], {"": ["a", "b"]}), + (["a=1; flag; b=2"], {"a": "1", "": "flag", "b": "2"}), ], ) def test_parse_cookies_keeps_every_value(cookie_strs, expected):