diff --git a/aikido_firewall/context/__init__.py b/aikido_firewall/context/__init__.py index 53910f4ab..223ba69a4 100644 --- a/aikido_firewall/context/__init__.py +++ b/aikido_firewall/context/__init__.py @@ -5,6 +5,8 @@ import threading SUPPORTED_SOURCES = ["django", "flask"] +UINPUT_SOURCES = ["body", "cookies", "query", "headers"] + local = threading.local() diff --git a/aikido_firewall/vulnerabilities/nosql_injection/__init__.py b/aikido_firewall/vulnerabilities/nosql_injection/__init__.py new file mode 100644 index 000000000..0637fad11 --- /dev/null +++ b/aikido_firewall/vulnerabilities/nosql_injection/__init__.py @@ -0,0 +1,114 @@ +""" +init.py file for the module to detect NoSQL Injections +""" + +import json +from aikido_firewall.helpers.is_plain_object import is_plain_object +from aikido_firewall.helpers.build_path_to_payload import build_path_to_payload +from aikido_firewall.helpers.try_decode_as_jwt import try_decode_as_jwt +from aikido_firewall.context import UINPUT_SOURCES + + +def match_filter_part_in_user(user_input, filter_part, path_to_payload=None): + """ + This tries to match a filter part to a part in user input + """ + if not path_to_payload: + path_to_payload = [] + if isinstance(user_input, str): + jwt = try_decode_as_jwt(user_input) + if jwt[0]: + return match_filter_part_in_user( + jwt[1], filter_part, path_to_payload + [{"type": "jwt"}] + ) + if user_input == filter_part: + return {"match": True, "pathToPayload": build_path_to_payload(path_to_payload)} + + if is_plain_object(user_input): + for key in user_input: + match = match_filter_part_in_user( + user_input[key], + filter_part, + path_to_payload + [{"type": "object", "key": key}], + ) + if match.get("match"): + return match + if isinstance(user_input, list): + for index, value in enumerate(user_input): + match = match_filter_part_in_user( + value, + filter_part, + path_to_payload + [{"type": "array", "index": index}], + ) + if match.get("match"): + return match + + return {"match": False} + + +def remove_keys_that_dont_start_with_dollar_sign(filter): + """ + This removes key that don't start with $, since they are not dangerous + """ + return {key: value for key, value in filter.items() if key.startswith("$")} + + +def find_filter_part_with_operators(user_input, part_of_filter): + """ + This looks for parts in the filter that have NSQL operators (e.g. $) + """ + if is_plain_object(part_of_filter): + obj = remove_keys_that_dont_start_with_dollar_sign(part_of_filter) + if len(obj) > 0: + result = match_filter_part_in_user(user_input, obj) + + if result.get("match"): + return { + "found": True, + "pathToPayload": result.get("pathToPayload"), + "payload": obj, + } + for key in part_of_filter: + result = find_filter_part_with_operators(user_input, part_of_filter[key]) + + if result.get("found"): + return { + "found": True, + "pathToPayload": result.get("pathToPayload"), + "payload": result.get("payload"), + } + + if isinstance(part_of_filter, list): + for val in part_of_filter: + result = find_filter_part_with_operators(user_input, val) + if result.get("found"): + return { + "found": True, + "pathToPayload": result.get("pathToPayload"), + "payload": result.get("payload"), + } + + return {"found": False} + + +def detect_nosql_injection(request, _filter): + """ + Give a context object and a nosql filter and this function + checks if there is a NoSQL injection + """ + if not is_plain_object(_filter) and not isinstance(_filter, list): + return {"injection": False} + + for source in UINPUT_SOURCES: + if request.get(source): + result = find_filter_part_with_operators(request[source], _filter) + + if result.get("found"): + return { + "injection": True, + "source": source, + "pathToPayload": result.get("pathToPayload"), + "payload": result.get("payload"), + } + + return {"injection": False} diff --git a/aikido_firewall/vulnerabilities/nosql_injection/init_test.py b/aikido_firewall/vulnerabilities/nosql_injection/init_test.py new file mode 100644 index 000000000..d3458e688 --- /dev/null +++ b/aikido_firewall/vulnerabilities/nosql_injection/init_test.py @@ -0,0 +1,479 @@ +import pytest +from aikido_firewall.vulnerabilities.nosql_injection import detect_nosql_injection + + +@pytest.fixture +def create_context(): + def _create_context( + query=None, headers=None, body=None, cookies=None, route_params=None + ): + context = { + "remote_address": "::1", + "method": "GET", + "url": "http://localhost:4000", + "query": query if query else {}, + "headers": headers if headers else {}, + "body": body, + "cookies": cookies if cookies else {}, + "route_params": route_params if route_params else {}, + "source": "express", + "route": "/posts/:id", + } + return context + + return _create_context + + +def test_empty_filter_and_request(create_context): + assert detect_nosql_injection(create_context(), {}) == {"injection": False} + + +def test_ignore_if_filter_not_object(create_context): + assert detect_nosql_injection(create_context(), "abc") == {"injection": False} + + +def test_ignore_if_and_not_array(create_context): + assert detect_nosql_injection(create_context(), {"$and": "abc"}) == { + "injection": False + } + + +def test_ignore_if_or_not_array(create_context): + assert detect_nosql_injection(create_context(), {"$or": "abc"}) == { + "injection": False + } + + +def test_ignore_if_nor_not_array(create_context): + assert detect_nosql_injection(create_context(), {"$nor": "abc"}) == { + "injection": False + } + + +def test_ignore_if_nor_empty_array(create_context): + assert detect_nosql_injection(create_context(), {"$nor": []}) == { + "injection": False + } + + +def test_ignore_if_not_not_object(create_context): + assert detect_nosql_injection(create_context(), {"$not": "abc"}) == { + "injection": False + } + + +def test_filter_with_string_value_and_empty_request(create_context): + assert detect_nosql_injection(create_context(), {"title": {"title": "title"}}) == { + "injection": False + } + + +def test_filter_with_ne_and_empty_request(create_context): + assert detect_nosql_injection(create_context(), {"title": {"$ne": None}}) == { + "injection": False + } + + +def test_using_gt_in_query_parameter(create_context): + assert detect_nosql_injection( + create_context(query={"title": {"$gt": ""}}), {"title": {"$gt": ""}} + ) == { + "injection": True, + "source": "query", + "pathToPayload": ".title", + "payload": {"$gt": ""}, + } + + +def test_safe_filter(create_context): + assert detect_nosql_injection( + create_context(query={"title": "title"}), {"$and": [{"title": "title"}]} + ) == {"injection": False} + + +def test_using_ne_in_body(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), {"title": {"$ne": None}} + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_in_body_different_name(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), {"myTitle": {"$ne": None}} + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_in_headers_with_different_name(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), {"someField": {"$ne": None}} + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_inside_and(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), + {"$and": [{"title": {"$ne": None}}, {"published": True}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_inside_or(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), + {"$or": [{"title": {"$ne": None}}, {"published": True}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_inside_nor(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), + {"$nor": [{"title": {"$ne": None}}, {"published": True}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_inside_not(create_context): + assert detect_nosql_injection( + create_context(body={"title": {"$ne": None}}), + {"$not": {"title": {"$ne": None}}}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".title", + "payload": {"$ne": None}, + } + + +def test_using_ne_nested_in_body(create_context): + assert detect_nosql_injection( + create_context(body={"nested": {"nested": {"$ne": None}}}), + {"$not": {"title": {"$ne": None}}}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".nested.nested", + "payload": {"$ne": None}, + } + + +def test_using_ne_in_jwt_in_headers(create_context): + assert detect_nosql_injection( + create_context( + # JWT token with the following payload: + # { + # "sub": "1234567890", + # "username": { + # "$ne": null + # }, + # "iat": 1516239022 + # } + headers={ + "Authorization": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + } + ), + {"username": {"$ne": None}}, + ) == { + "injection": True, + "source": "headers", + "pathToPayload": ".Authorization.username", + "payload": {"$ne": None}, + } + + +def test_using_ne_in_jwt_in_bearer_header(create_context): + assert detect_nosql_injection( + create_context( + # JWT token with the following payload: + # { + # "sub": "1234567890", + # "username": { + # "$ne": null + # }, + # "iat": 1516239022 + # } + headers={ + "Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + } + ), + {"username": {"$ne": None}}, + ) == { + "injection": True, + "source": "headers", + "pathToPayload": ".Authorization.username", + "payload": {"$ne": None}, + } + + +def test_using_ne_in_jwt_in_cookies(create_context): + assert detect_nosql_injection( + create_context( + # JWT token with the following payload: + # { + # "sub": "1234567890", + # "username": { + # "$ne": null + # }, + # "iat": 1516239022 + # } + cookies={ + "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbWUiOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + } + ), + {"username": {"$ne": None}}, + ) == { + "injection": True, + "source": "cookies", + "pathToPayload": ".session.username", + "payload": {"$ne": None}, + } + + +def test_jwt_lookalike(create_context): + assert detect_nosql_injection( + create_context( + cookies={ + "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwidXNlcm5hbW!iOnsiJG5lIjpudWxsfSwiaWF0IjoxNTE2MjM5MDIyfQ._jhGJw9WzB6gHKPSozTFHDo9NOHs3CNOlvJ8rWy6VrQ" + } + ), + {"username": {"$ne": None}}, + ) == {"injection": False} + + +def test_using_gt_in_query_parameter(create_context): + assert detect_nosql_injection( + create_context(query={"age": {"$gt": "21"}}), {"age": {"$gt": "21"}} + ) == { + "injection": True, + "source": "query", + "pathToPayload": ".age", + "payload": {"$gt": "21"}, + } + + +def test_using_gt_and_lt_in_query_parameter(create_context): + assert detect_nosql_injection( + create_context(body={"age": {"$gt": "21", "$lt": "100"}}), + {"age": {"$gt": "21", "$lt": "100"}}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".age", + "payload": {"$gt": "21", "$lt": "100"}, + } + + +def test_using_gt_and_lt_in_query_parameter_different_name(create_context): + assert detect_nosql_injection( + create_context(body={"age": {"$gt": "21", "$lt": "100"}}), + {"myAge": {"$gt": "21", "$lt": "100"}}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".age", + "payload": {"$gt": "21", "$lt": "100"}, + } + + +def test_using_gt_and_lt_in_query_parameter_nested(create_context): + assert detect_nosql_injection( + create_context( + body={"nested": {"nested": {"age": {"$gt": "21", "$lt": "100"}}}} + ), + {"$and": [{"someAgeField": {"$gt": "21", "$lt": "100"}}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".nested.nested.age", + "payload": {"$gt": "21", "$lt": "100"}, + } + + +def test_using_gt_and_lt_in_query_parameter_root(create_context): + assert detect_nosql_injection( + create_context(body={"$and": [{"someAgeField": {"$gt": "21", "$lt": "100"}}]}), + {"$and": [{"someAgeField": {"$gt": "21", "$lt": "100"}}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".", + "payload": {"$and": [{"someAgeField": {"$gt": "21", "$lt": "100"}}]}, + } + + +def test_where(create_context): + assert detect_nosql_injection( + create_context(body={"$and": [{"$where": "sleep(1000)"}]}), + {"$and": [{"$where": "sleep(1000)"}]}, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".", + "payload": {"$and": [{"$where": "sleep(1000)"}]}, + } + + +def test_array_body(create_context): + assert detect_nosql_injection( + create_context( + body=[ + { + "$where": "sleep(1000)", + }, + ] + ), + { + "$and": [ + { + "$where": "sleep(1000)", + }, + ], + }, + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".[0]", + "payload": {"$where": "sleep(1000)"}, + } + + +def test_safe_email_password(create_context): + assert detect_nosql_injection( + create_context( + body={ + "email": "email", + "password": "password", + } + ), + { + "email": "email", + "password": "password", + }, + ) == {"injection": False} + + +def test_flags_pipeline_aggregations(create_context): + assert detect_nosql_injection( + create_context( + body=[ + { + "$lookup": { + "from": "users", + "localField": "Dummy-IdontExist", + "foreignField": "Dummy-IdontExist", + "as": "user_docs", + }, + }, + { + "$limit": 1, + }, + ] + ), + [ + { + "$lookup": { + "from": "users", + "localField": "Dummy-IdontExist", + "foreignField": "Dummy-IdontExist", + "as": "user_docs", + }, + }, + { + "$limit": 1, + }, + ], + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".[0]", + "payload": { + "$lookup": { + "from": "users", + "localField": "Dummy-IdontExist", + "foreignField": "Dummy-IdontExist", + "as": "user_docs", + }, + }, + } + + assert detect_nosql_injection( + create_context( + body={ + "username": { + "$gt": "", + }, + } + ), + [ + { + "$match": { + "username": { + "$gt": "", + }, + }, + }, + { + "$group": { + "_id": "$username", + "count": {"$sum": 1}, + }, + }, + ], + ) == { + "injection": True, + "source": "body", + "pathToPayload": ".username", + "payload": { + "$gt": "", + }, + } + + +def test_ignores_safe_pipeline_aggregations(create_context): + assert detect_nosql_injection( + create_context( + body={ + "username": "admin", + } + ), + [ + { + "$match": { + "username": "admin", + }, + }, + { + "$group": { + "_id": "$username", + "count": {"$sum": 1}, + }, + }, + ], + ) == {"injection": False} diff --git a/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py b/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py index 83ec77f38..77c205ae2 100644 --- a/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py +++ b/aikido_firewall/vulnerabilities/sql_injection/check_context_for_sql_injection.py @@ -8,8 +8,7 @@ ) from aikido_firewall.vulnerabilities.sql_injection import detect_sql_injection from aikido_firewall.helpers.logging import logger - -SOURCES = ["body", "cookies", "query", "headers"] +from aikido_firewall.context import UINPUT_SOURCES as SOURCES def check_context_for_sql_injection(sql, operation, context, dialect):