From 0c9085cc016b4b4c143856e5d5f77e03b2dcd414 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 16:54:34 +0100 Subject: [PATCH 1/7] Create an enable_idor_protection.py and an idor_protection_config.py --- aikido_zen/storage/idor_protection_config.py | 21 +++++++ .../idor/enable_idor_protection.py | 40 ++++++++++++++ .../idor/enable_idor_protection_test.py | 55 +++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 aikido_zen/storage/idor_protection_config.py create mode 100644 aikido_zen/vulnerabilities/idor/enable_idor_protection.py create mode 100644 aikido_zen/vulnerabilities/idor/enable_idor_protection_test.py diff --git a/aikido_zen/storage/idor_protection_config.py b/aikido_zen/storage/idor_protection_config.py new file mode 100644 index 000000000..633226eec --- /dev/null +++ b/aikido_zen/storage/idor_protection_config.py @@ -0,0 +1,21 @@ +class IdorProtectionConfig: + def __init__(self, tenant_column_name, excluded_tables): + self.tenant_column_name = tenant_column_name + self.excluded_tables = excluded_tables + + +class IdorProtectionStore: + def __init__(self): + self.config = None + + def get(self): + return self.config + + def set(self, config): + self.config = config + + def clear(self): + self.config = None + + +idor_protection_store = IdorProtectionStore() diff --git a/aikido_zen/vulnerabilities/idor/enable_idor_protection.py b/aikido_zen/vulnerabilities/idor/enable_idor_protection.py new file mode 100644 index 000000000..e65319f1f --- /dev/null +++ b/aikido_zen/vulnerabilities/idor/enable_idor_protection.py @@ -0,0 +1,40 @@ +from aikido_zen.helpers.logging import logger +from aikido_zen.storage.idor_protection_config import ( + IdorProtectionConfig, + idor_protection_store, +) + + +def enable_idor_protection(tenant_column_name: str, excluded_tables=None): + if not isinstance(tenant_column_name, str): + logger.info( + "enable_idor_protection(...) expects tenant_column_name to be a string, found %s instead.", + type(tenant_column_name), + ) + return + + if len(tenant_column_name) == 0: + logger.info( + "enable_idor_protection(...) expects tenant_column_name to be a non-empty string." + ) + return + + if excluded_tables is None: + excluded_tables = [] + + if not isinstance(excluded_tables, list): + logger.info( + "enable_idor_protection(...) expects excluded_tables to be a list, found %s instead.", + type(excluded_tables), + ) + return + + for table in excluded_tables: + if not isinstance(table, str): + logger.info( + "enable_idor_protection(...) expects excluded_tables to contain strings, found %s instead.", + type(table), + ) + return + + idor_protection_store.set(IdorProtectionConfig(tenant_column_name, excluded_tables)) diff --git a/aikido_zen/vulnerabilities/idor/enable_idor_protection_test.py b/aikido_zen/vulnerabilities/idor/enable_idor_protection_test.py new file mode 100644 index 000000000..f4c4b5c96 --- /dev/null +++ b/aikido_zen/vulnerabilities/idor/enable_idor_protection_test.py @@ -0,0 +1,55 @@ +import pytest +from aikido_zen.storage.idor_protection_config import idor_protection_store +from .enable_idor_protection import enable_idor_protection + + +@pytest.fixture(autouse=True) +def run_around_tests(): + yield + idor_protection_store.clear() + + +def test_enable_basic(): + enable_idor_protection("tenant_id") + config = idor_protection_store.get() + assert config is not None + assert config.tenant_column_name == "tenant_id" + assert config.excluded_tables == [] + + +def test_enable_with_excluded_tables(): + enable_idor_protection("org_id", excluded_tables=["migrations", "sessions"]) + config = idor_protection_store.get() + assert config is not None + assert config.tenant_column_name == "org_id" + assert config.excluded_tables == ["migrations", "sessions"] + + +def test_invalid_column_name_type(caplog): + enable_idor_protection(123) + assert idor_protection_store.get() is None + assert "expects tenant_column_name to be a string" in caplog.text + + +def test_empty_column_name(caplog): + enable_idor_protection("") + assert idor_protection_store.get() is None + assert "non-empty string" in caplog.text + + +def test_invalid_excluded_tables_type(caplog): + enable_idor_protection("tenant_id", excluded_tables="not_a_list") + assert idor_protection_store.get() is None + assert "expects excluded_tables to be a list" in caplog.text + + +def test_invalid_excluded_table_item(caplog): + enable_idor_protection("tenant_id", excluded_tables=[123]) + assert idor_protection_store.get() is None + assert "expects excluded_tables to contain strings" in caplog.text + + +def test_none_column_name(caplog): + enable_idor_protection(None) + assert idor_protection_store.get() is None + assert "expects tenant_column_name to be a string" in caplog.text From 4c92231581917d45b144b90417a3e00f22472c1d Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 16:54:49 +0100 Subject: [PATCH 2/7] create idor/ --- aikido_zen/vulnerabilities/idor/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 aikido_zen/vulnerabilities/idor/__init__.py diff --git a/aikido_zen/vulnerabilities/idor/__init__.py b/aikido_zen/vulnerabilities/idor/__init__.py new file mode 100644 index 000000000..e69de29bb From aea99d7f47e216510f3c9c1cd48a42a162da497f Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 17:04:50 +0100 Subject: [PATCH 3/7] Add first check_idor.py --- aikido_zen/vulnerabilities/idor/check_idor.py | 164 +++++++++++++ .../vulnerabilities/idor/check_idor_test.py | 230 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 aikido_zen/vulnerabilities/idor/check_idor.py create mode 100644 aikido_zen/vulnerabilities/idor/check_idor_test.py diff --git a/aikido_zen/vulnerabilities/idor/check_idor.py b/aikido_zen/vulnerabilities/idor/check_idor.py new file mode 100644 index 000000000..037126e8a --- /dev/null +++ b/aikido_zen/vulnerabilities/idor/check_idor.py @@ -0,0 +1,164 @@ +from aikido_zen.context import get_current_context +from aikido_zen.errors import AikidoIDOR +from aikido_zen.helpers.logging import logger +from aikido_zen.ratelimiting.lru_cache import LRUCache +from aikido_zen.thread.thread_cache import get_cache +from aikido_zen.storage.idor_protection_config import idor_protection_store +from .analyze_sql import analyze_sql + +_sql_cache = LRUCache(max_items=200, time_to_live_in_ms=600000) + + +def run_idor_check(query, dialect, query_params=None): + config = idor_protection_store.get() + if not config: + return + + context = get_current_context() + if not context: + return + + if context.idor_ignored: + return + + if not context.tenant_id: + return + + thread_cache = get_cache() + if thread_cache and thread_cache.is_bypassed_ip(context.remote_address): + return + + if not isinstance(query, str): + return + + cache_key = query + ":" + dialect + statements = _sql_cache.get(cache_key) + if statements is None: + statements = analyze_sql(query, dialect) + if statements is not None: + _sql_cache.set(cache_key, statements) + + if not statements: + return + + # Rust returns {"error": ...} for queries it cannot parse (e.g. %s placeholders) + if isinstance(statements, dict) and "error" in statements: + logger.debug("IDOR analyze_sql error: %s", statements["error"]) + return + + if not isinstance(statements, list): + return + + tenant_column = config.tenant_column_name + excluded = config.excluded_tables + + for stmt in statements: + if stmt.get("kind") == "insert": + _check_insert( + stmt, tenant_column, excluded, context.tenant_id, query_params + ) + else: + _check_where_filters( + stmt, tenant_column, excluded, context.tenant_id, query_params + ) + + +def _check_where_filters(stmt, tenant_column, excluded, tenant_id, query_params): + tables = stmt.get("tables", []) + filters = stmt.get("filters", []) + + for table_info in tables: + table_name = table_info.get("name", "") + if table_name in excluded: + continue + + matching_filter = _find_tenant_filter( + filters, tenant_column, table_name, len(tables) == 1 + ) + + if matching_filter is None: + raise AikidoIDOR( + f"Zen IDOR protection: query on table '{table_name}' " + f"is missing a filter on column '{tenant_column}'" + ) + + value = _resolve_value(matching_filter, query_params) + if value is not None and str(value) != tenant_id: + raise AikidoIDOR( + f"Zen IDOR protection: query on table '{table_name}' " + f"filters '{tenant_column}' with value '{value}' " + f"but tenant ID is '{tenant_id}'" + ) + + +def _check_insert(stmt, tenant_column, excluded, tenant_id, query_params): + tables = stmt.get("tables", []) + rows = stmt.get("insert_columns", []) + + for table_info in tables: + table_name = table_info.get("name", "") + if table_name in excluded: + continue + + for row in rows: + col_entry = next((e for e in row if e.get("column") == tenant_column), None) + + if col_entry is None: + raise AikidoIDOR( + f"Zen IDOR protection: INSERT on table '{table_name}' " + f"is missing column '{tenant_column}'" + ) + + value = _resolve_value(col_entry, query_params) + if value is not None and str(value) != tenant_id: + raise AikidoIDOR( + f"Zen IDOR protection: INSERT on table '{table_name}' " + f"sets '{tenant_column}' to '{value}' " + f"but tenant ID is '{tenant_id}'" + ) + + +def _find_tenant_filter(filters, tenant_column, table_name, single_table): + for f in filters: + if f.get("column") != tenant_column: + continue + filter_table = f.get("table") + if filter_table: + if filter_table == table_name: + return f + elif single_table: + return f + return None + + +def _resolve_value(entry, query_params): + if not entry.get("is_placeholder", False): + return entry.get("value") + + if not query_params or isinstance(query_params, dict): + return None + + params = query_params + if not isinstance(params, (list, tuple)): + params = list(params) if hasattr(params, "__iter__") else None + if params is None: + return None + + # MySQL-style ? placeholder: index is 0-based placeholder_number + if "placeholder_number" in entry: + idx = entry["placeholder_number"] + if isinstance(idx, int) and 0 <= idx < len(params): + return str(params[idx]) + return None + + # Postgres-style $N placeholder: N is 1-based + value = entry.get("value", "") + if isinstance(value, str) and value.startswith("$"): + try: + idx = int(value[1:]) - 1 + if 0 <= idx < len(params): + return str(params[idx]) + except (ValueError, IndexError): + pass + + return None diff --git a/aikido_zen/vulnerabilities/idor/check_idor_test.py b/aikido_zen/vulnerabilities/idor/check_idor_test.py new file mode 100644 index 000000000..8d27684ce --- /dev/null +++ b/aikido_zen/vulnerabilities/idor/check_idor_test.py @@ -0,0 +1,230 @@ +import pytest +from aikido_zen.context import current_context, Context +from aikido_zen.errors import AikidoIDOR +from aikido_zen.storage.idor_protection_config import ( + IdorProtectionConfig, + idor_protection_store, +) +from .check_idor import run_idor_check, _sql_cache + + +@pytest.fixture(autouse=True) +def run_around_tests(): + _sql_cache.clear() + yield + current_context.set(None) + idor_protection_store.clear() + _sql_cache.clear() + + +def _setup(tenant_id="1", idor_ignored=False, column="tenant_id", excluded=None): + idor_protection_store.set(IdorProtectionConfig(column, excluded or [])) + wsgi_request = { + "REQUEST_METHOD": "GET", + "wsgi.url_scheme": "http", + "HTTP_HOST": "localhost:8080", + "PATH_INFO": "/hello", + "QUERY_STRING": "", + "CONTENT_TYPE": "application/json", + "REMOTE_ADDR": "198.51.100.23", + } + ctx = Context(req=wsgi_request, body=None, source="flask") + ctx.tenant_id = tenant_id + ctx.idor_ignored = idor_ignored + ctx.set_as_current_context() + return ctx + + +# === SELECT tests === + + +def test_select_with_correct_tenant_literal(): + _setup(tenant_id="1") + # Should not raise + run_idor_check("SELECT * FROM users WHERE tenant_id = 1", "postgres") + + +def test_select_missing_tenant_filter(): + _setup(tenant_id="1") + with pytest.raises(AikidoIDOR, match="missing a filter on column 'tenant_id'"): + run_idor_check("SELECT * FROM users WHERE name = 'test'", "postgres") + + +def test_select_wrong_tenant_id(): + _setup(tenant_id="1") + with pytest.raises( + AikidoIDOR, match="filters 'tenant_id' with value '2'.*tenant ID is '1'" + ): + run_idor_check("SELECT * FROM users WHERE tenant_id = 2", "postgres") + + +def test_select_with_dollar_placeholder_correct(): + _setup(tenant_id="42") + run_idor_check("SELECT * FROM users WHERE tenant_id = $1", "postgres", ["42"]) + + +def test_select_with_dollar_placeholder_wrong(): + _setup(tenant_id="42") + with pytest.raises( + AikidoIDOR, match="filters 'tenant_id' with value '99'.*tenant ID is '42'" + ): + run_idor_check("SELECT * FROM users WHERE tenant_id = $1", "postgres", ["99"]) + + +def test_select_with_question_mark_placeholder_correct(): + _setup(tenant_id="10") + run_idor_check("SELECT * FROM users WHERE tenant_id = ?", "mysql", [10]) + + +def test_select_with_question_mark_placeholder_wrong(): + _setup(tenant_id="10") + with pytest.raises( + AikidoIDOR, match="filters 'tenant_id' with value '20'.*tenant ID is '10'" + ): + run_idor_check("SELECT * FROM users WHERE tenant_id = ?", "mysql", [20]) + + +# === INSERT tests === + + +def test_insert_with_correct_tenant(): + _setup(tenant_id="1") + run_idor_check("INSERT INTO users (name, tenant_id) VALUES ('test', 1)", "postgres") + + +def test_insert_missing_tenant_column(): + _setup(tenant_id="1") + with pytest.raises( + AikidoIDOR, match="INSERT on table 'users' is missing column 'tenant_id'" + ): + run_idor_check("INSERT INTO users (name) VALUES ('test')", "postgres") + + +def test_insert_wrong_tenant_id(): + _setup(tenant_id="1") + with pytest.raises( + AikidoIDOR, + match="INSERT on table 'users' sets 'tenant_id' to '2'.*tenant ID is '1'", + ): + run_idor_check( + "INSERT INTO users (name, tenant_id) VALUES ('test', 2)", "postgres" + ) + + +def test_insert_with_placeholder_correct(): + _setup(tenant_id="5") + run_idor_check( + "INSERT INTO users (name, tenant_id) VALUES (?, ?)", "mysql", ["test", 5] + ) + + +def test_insert_with_placeholder_wrong(): + _setup(tenant_id="5") + with pytest.raises( + AikidoIDOR, match="INSERT on table 'users' sets 'tenant_id' to '9'" + ): + run_idor_check( + "INSERT INTO users (name, tenant_id) VALUES (?, ?)", "mysql", ["test", 9] + ) + + +def test_multi_row_insert_second_row_wrong(): + _setup(tenant_id="1") + with pytest.raises( + AikidoIDOR, match="INSERT on table 'users' sets 'tenant_id' to '2'" + ): + run_idor_check( + "INSERT INTO users (name, tenant_id) VALUES ('a', 1), ('b', 2)", + "postgres", + ) + + +# === UPDATE / DELETE tests === + + +def test_update_with_correct_tenant(): + _setup(tenant_id="1") + run_idor_check("UPDATE users SET name = 'test' WHERE tenant_id = 1", "postgres") + + +def test_update_missing_tenant_filter(): + _setup(tenant_id="1") + with pytest.raises(AikidoIDOR, match="missing a filter on column 'tenant_id'"): + run_idor_check("UPDATE users SET name = 'test' WHERE name = 'old'", "postgres") + + +def test_delete_with_correct_tenant(): + _setup(tenant_id="1") + run_idor_check("DELETE FROM users WHERE tenant_id = 1", "postgres") + + +def test_delete_missing_tenant_filter(): + _setup(tenant_id="1") + with pytest.raises(AikidoIDOR, match="missing a filter on column 'tenant_id'"): + run_idor_check("DELETE FROM users WHERE name = 'test'", "postgres") + + +# === Guard / bypass tests === + + +def test_no_config_does_nothing(): + idor_protection_store.clear() + run_idor_check("SELECT * FROM users", "postgres") + + +def test_no_context_does_nothing(): + idor_protection_store.set(IdorProtectionConfig("tenant_id", [])) + current_context.set(None) + run_idor_check("SELECT * FROM users", "postgres") + + +def test_idor_ignored_skips_check(): + _setup(tenant_id="1", idor_ignored=True) + # Should not raise even though filter is missing + run_idor_check("SELECT * FROM users WHERE name = 'test'", "postgres") + + +def test_no_tenant_id_skips_check(): + _setup(tenant_id=None) + run_idor_check("SELECT * FROM users WHERE name = 'test'", "postgres") + + +def test_excluded_table(): + _setup(tenant_id="1", excluded=["sessions"]) + # sessions is excluded, should not raise + run_idor_check("SELECT * FROM sessions WHERE name = 'test'", "postgres") + + +def test_percent_s_placeholder_skipped(): + _setup(tenant_id="1") + # %s queries cause a parse error, should be skipped gracefully + run_idor_check("SELECT * FROM users WHERE tenant_id = %s", "postgres", [1]) + + +def test_non_string_query_skipped(): + _setup(tenant_id="1") + run_idor_check(123, "postgres") + + +def test_table_qualified_filter_matches(): + _setup(tenant_id="1") + run_idor_check("SELECT * FROM users WHERE users.tenant_id = 1", "postgres") + + +def test_dollar_placeholder_with_multiple_params(): + _setup(tenant_id="42") + run_idor_check( + "SELECT * FROM users WHERE name = $1 AND tenant_id = $2", + "postgres", + ["test", "42"], + ) + + +def test_dollar_placeholder_wrong_with_multiple_params(): + _setup(tenant_id="42") + with pytest.raises(AikidoIDOR): + run_idor_check( + "SELECT * FROM users WHERE name = $1 AND tenant_id = $2", + "postgres", + ["test", "99"], + ) From 830fcd1057b451915de2da2633a6f40a1e0c899b Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 17:05:08 +0100 Subject: [PATCH 4/7] sinks: run idor checks [wip] --- aikido_zen/sinks/asyncpg.py | 5 +++++ aikido_zen/sinks/clickhouse_driver.py | 4 ++++ aikido_zen/sinks/mysqlclient.py | 7 +++++++ aikido_zen/sinks/psycopg.py | 4 ++++ aikido_zen/sinks/psycopg2.py | 4 ++++ aikido_zen/sinks/pymysql.py | 7 +++++++ 6 files changed, 31 insertions(+) diff --git a/aikido_zen/sinks/asyncpg.py b/aikido_zen/sinks/asyncpg.py index b5a7a0347..913295b68 100644 --- a/aikido_zen/sinks/asyncpg.py +++ b/aikido_zen/sinks/asyncpg.py @@ -6,6 +6,7 @@ from aikido_zen.helpers.get_argument import get_argument from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import patch_function, before, on_import +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @before @@ -17,6 +18,10 @@ def _execute(func, instance, args, kwargs): vulns.run_vulnerability_scan(kind="sql_injection", op=op, args=(query, "postgres")) + # asyncpg uses variadic positional args for params: execute(query, *args) + query_params = args[1:] if len(args) > 1 else None + run_idor_check(query, "postgres", query_params) + @on_import("asyncpg.connection", "asyncpg", version_requirement="0.27.0") def patch(m): diff --git a/aikido_zen/sinks/clickhouse_driver.py b/aikido_zen/sinks/clickhouse_driver.py index d577acb73..b100eaf0e 100644 --- a/aikido_zen/sinks/clickhouse_driver.py +++ b/aikido_zen/sinks/clickhouse_driver.py @@ -2,6 +2,7 @@ from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import before, on_import, patch_function from aikido_zen.vulnerabilities import run_vulnerability_scan +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @before @@ -13,6 +14,9 @@ def _execute(func, instance, args, kwargs): run_vulnerability_scan("sql_injection", op, args=(query, "clickhouse")) + query_params = get_argument(args, kwargs, 1, "params") + run_idor_check(query, "clickhouse", query_params) + @on_import("clickhouse_driver", package="clickhouse_driver") def patch(m): diff --git a/aikido_zen/sinks/mysqlclient.py b/aikido_zen/sinks/mysqlclient.py index c8a445659..e2822a292 100644 --- a/aikido_zen/sinks/mysqlclient.py +++ b/aikido_zen/sinks/mysqlclient.py @@ -6,6 +6,7 @@ import aikido_zen.vulnerabilities as vulns from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import patch_function, on_import, before +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @before @@ -20,6 +21,9 @@ def _execute(func, instance, args, kwargs): kind="sql_injection", op="MySQLdb.Cursor.execute", args=(query, "mysql") ) + query_params = get_argument(args, kwargs, 1, "args") + run_idor_check(query, "mysql", query_params) + @before def _executemany(func, instance, args, kwargs): @@ -30,6 +34,9 @@ def _executemany(func, instance, args, kwargs): kind="sql_injection", op="MySQLdb.Cursor.executemany", args=(query, "mysql") ) + query_params = get_argument(args, kwargs, 1, "args") + run_idor_check(query, "mysql", query_params) + @on_import("MySQLdb.cursors", "mysqlclient", version_requirement="1.5.0") def patch(m): diff --git a/aikido_zen/sinks/psycopg.py b/aikido_zen/sinks/psycopg.py index 5518848ef..c56375df4 100644 --- a/aikido_zen/sinks/psycopg.py +++ b/aikido_zen/sinks/psycopg.py @@ -6,6 +6,7 @@ from aikido_zen.helpers.get_argument import get_argument from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import patch_function, on_import, before +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @before @@ -25,6 +26,9 @@ def _execute(func, instance, args, kwargs): op = f"psycopg.{instance.__class__.__name__}.{func.__name__}" vulns.run_vulnerability_scan(kind="sql_injection", op=op, args=(query, "postgres")) + query_params = get_argument(args, kwargs, 1, "params") + run_idor_check(query, "postgres", query_params) + @on_import("psycopg.cursor", "psycopg", version_requirement="3.1.0") def patch(m): diff --git a/aikido_zen/sinks/psycopg2.py b/aikido_zen/sinks/psycopg2.py index c7e7e2ac1..ba4d17b72 100644 --- a/aikido_zen/sinks/psycopg2.py +++ b/aikido_zen/sinks/psycopg2.py @@ -7,6 +7,7 @@ from aikido_zen.helpers.get_argument import get_argument from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import on_import, before, patch_function, after +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @after @@ -42,6 +43,9 @@ def psycopg2_patch(func, instance, args, kwargs): vulns.run_vulnerability_scan(kind="sql_injection", op=op, args=(query, "postgres")) + query_params = get_argument(args, kwargs, 1, "vars") + run_idor_check(query, "postgres", query_params) + @on_import("psycopg2") def patch(m): diff --git a/aikido_zen/sinks/pymysql.py b/aikido_zen/sinks/pymysql.py index 9405473e7..b302bf6d1 100644 --- a/aikido_zen/sinks/pymysql.py +++ b/aikido_zen/sinks/pymysql.py @@ -6,6 +6,7 @@ from aikido_zen.helpers.get_argument import get_argument from aikido_zen.helpers.register_call import register_call from aikido_zen.sinks import patch_function, on_import, before +from aikido_zen.vulnerabilities.idor.check_idor import run_idor_check @before @@ -20,6 +21,9 @@ def _execute(func, instance, args, kwargs): kind="sql_injection", op="pymysql.Cursor.execute", args=(query, "mysql") ) + query_params = get_argument(args, kwargs, 1, "args") + run_idor_check(query, "mysql", query_params) + @before def _executemany(func, instance, args, kwargs): @@ -30,6 +34,9 @@ def _executemany(func, instance, args, kwargs): kind="sql_injection", op="pymysql.Cursor.executemany", args=(query, "mysql") ) + query_params = get_argument(args, kwargs, 1, "args") + run_idor_check(query, "mysql", query_params) + @on_import("pymysql.cursors", "pymysql", version_requirement="0.9.0") def patch(m): From d1843c32e1efbd954a4c25400c5a5c4d6a95920a Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 17:05:18 +0100 Subject: [PATCH 5/7] Add AikidoIDOR error type --- aikido_zen/errors/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/aikido_zen/errors/__init__.py b/aikido_zen/errors/__init__.py index 18e9a9c7f..68343a2c3 100644 --- a/aikido_zen/errors/__init__.py +++ b/aikido_zen/errors/__init__.py @@ -61,3 +61,11 @@ class AikidoSSRF(AikidoException): """Exception because of SSRF""" kind = "ssrf" + + +class AikidoIDOR(AikidoException): + """Exception because of an IDOR vulnerability (missing or wrong tenant filter)""" + + def __init__(self, message): + super().__init__(message) + self.message = message From 593e8d02be7f7bd1b2869058e3cfe14194790a45 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 17:05:37 +0100 Subject: [PATCH 6/7] context: allow setting tenant_id --- aikido_zen/context/__init__.py | 1 + aikido_zen/context/set_tenant_id.py | 31 +++++++++ aikido_zen/context/set_tenant_id_test.py | 82 ++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 aikido_zen/context/set_tenant_id.py create mode 100644 aikido_zen/context/set_tenant_id_test.py diff --git a/aikido_zen/context/__init__.py b/aikido_zen/context/__init__.py index bb8eeb3b3..e72da7976 100644 --- a/aikido_zen/context/__init__.py +++ b/aikido_zen/context/__init__.py @@ -55,6 +55,7 @@ def __init__(self, context_obj=None, body=None, req=None, source=None): self.cookies = {} self.query = {} self.protection_forced_off = None + self.tenant_id = None # Parse WSGI/ASGI/... request : self.method = self.remote_address = self.url = None diff --git a/aikido_zen/context/set_tenant_id.py b/aikido_zen/context/set_tenant_id.py new file mode 100644 index 000000000..b6c341e13 --- /dev/null +++ b/aikido_zen/context/set_tenant_id.py @@ -0,0 +1,31 @@ +""" +Exports set_tenant_id for setting the tenant ID on the current context. +""" + +from aikido_zen.helpers.logging import logger +from . import get_current_context + + +def set_tenant_id(tenant_id): + """ + Sets the tenant ID on the current request context. + Used for IDOR protection to verify SQL queries filter on the correct tenant. + """ + if not isinstance(tenant_id, (str, int)): + logger.info( + "set_tenant_id(...) expects a string or integer, found %s instead.", + type(tenant_id), + ) + return + + str_id = str(tenant_id) + if len(str_id) == 0: + logger.info("set_tenant_id(...) expects a non-empty value.") + return + + context = get_current_context() + if not context: + logger.debug("No context set, cannot set tenant_id") + return + + context.tenant_id = str_id diff --git a/aikido_zen/context/set_tenant_id_test.py b/aikido_zen/context/set_tenant_id_test.py new file mode 100644 index 000000000..7b2e3b61a --- /dev/null +++ b/aikido_zen/context/set_tenant_id_test.py @@ -0,0 +1,82 @@ +import pytest +from . import current_context, Context +from .set_tenant_id import set_tenant_id + + +@pytest.fixture(autouse=True) +def run_around_tests(): + yield + current_context.set(None) + + +def _create_context(): + wsgi_request = { + "REQUEST_METHOD": "GET", + "HTTP_HEADER_1": "header 1 value", + "wsgi.url_scheme": "http", + "HTTP_HOST": "localhost:8080", + "PATH_INFO": "/hello", + "QUERY_STRING": "", + "CONTENT_TYPE": "application/json", + "REMOTE_ADDR": "198.51.100.23", + } + context = Context(req=wsgi_request, body=None, source="flask") + context.set_as_current_context() + return context + + +def test_set_tenant_id_string(): + ctx = _create_context() + set_tenant_id("tenant_123") + assert ctx.tenant_id == "tenant_123" + + +def test_set_tenant_id_integer(): + ctx = _create_context() + set_tenant_id(42) + assert ctx.tenant_id == "42" + + +def test_set_tenant_id_empty_string(caplog): + ctx = _create_context() + set_tenant_id("") + assert ctx.tenant_id is None + assert "non-empty" in caplog.text + + +def test_set_tenant_id_invalid_type(caplog): + ctx = _create_context() + set_tenant_id(12.34) + assert ctx.tenant_id is None + assert "expects a string or integer" in caplog.text + + +def test_set_tenant_id_none(caplog): + ctx = _create_context() + set_tenant_id(None) + assert ctx.tenant_id is None + assert "expects a string or integer" in caplog.text + + +def test_set_tenant_id_dict(caplog): + ctx = _create_context() + set_tenant_id({"id": 1}) + assert ctx.tenant_id is None + assert "expects a string or integer" in caplog.text + + +def test_set_tenant_id_no_context(caplog): + import logging + + # No context set — should not raise, tenant_id is not applied anywhere + with caplog.at_level(logging.DEBUG, logger="Zen"): + set_tenant_id("tenant_123") + assert "No context set" in caplog.text + + +def test_set_tenant_id_overwrites(): + ctx = _create_context() + set_tenant_id("first") + assert ctx.tenant_id == "first" + set_tenant_id("second") + assert ctx.tenant_id == "second" From 77ab06f8e9f6f418e1b9c79e2e160f9cf83cc321 Mon Sep 17 00:00:00 2001 From: BitterPanda Date: Tue, 24 Feb 2026 17:06:38 +0100 Subject: [PATCH 7/7] Re-export enable_idor_protection and set_tenant_id --- aikido_zen/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/aikido_zen/__init__.py b/aikido_zen/__init__.py index c65ba3509..6f1b3cfeb 100644 --- a/aikido_zen/__init__.py +++ b/aikido_zen/__init__.py @@ -8,6 +8,10 @@ # Re-export functions : from aikido_zen.context.users import set_user +from aikido_zen.context.set_tenant_id import set_tenant_id +from aikido_zen.vulnerabilities.idor.enable_idor_protection import ( + enable_idor_protection, +) from aikido_zen.helpers.check_gevent import check_gevent from aikido_zen.helpers.python_version_not_supported import python_version_not_supported from aikido_zen.middleware import should_block_request