From 7a0298a53d1277559fb5ef613000808b81318eb5 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 11:52:39 +0100 Subject: [PATCH 1/9] Initial commit IDOR protection --- docs/idor-protection.md | 139 +++++++++ lib/API.h | 7 + lib/php-extension/Action.cpp | 12 + lib/php-extension/Aikido.cpp | 3 + lib/php-extension/GoWrappers.cpp | 20 ++ lib/php-extension/Handle.cpp | 9 + lib/php-extension/HandleIdorProtection.cpp | 99 +++++++ lib/php-extension/HandleQueries.cpp | 145 ++++++++- lib/php-extension/include/Action.h | 1 + lib/php-extension/include/Cache.h | 6 + .../include/HandleIdorProtection.h | 18 ++ lib/php-extension/include/Includes.h | 1 + .../context/event_getters.go | 20 ++ .../handle_enable_idor_protection.go | 28 ++ lib/request-processor/handle_sql_queries.go | 11 + lib/request-processor/main.go | 1 + lib/request-processor/utils/utils.go | 2 +- .../vulnerabilities/idor/idor.go | 275 ++++++++++++++++++ .../zen-internals/zen_internals.go | 59 ++++ .../change_config_disable_blocking.json | 10 + tests/server/test_idor_protection/env.json | 3 + tests/server/test_idor_protection/index.php | 160 ++++++++++ .../test_idor_protection/start_config.json | 10 + tests/server/test_idor_protection/test.py | 111 +++++++ 24 files changed, 1146 insertions(+), 4 deletions(-) create mode 100644 docs/idor-protection.md create mode 100644 lib/php-extension/HandleIdorProtection.cpp create mode 100644 lib/php-extension/include/HandleIdorProtection.h create mode 100644 lib/request-processor/handle_enable_idor_protection.go create mode 100644 lib/request-processor/vulnerabilities/idor/idor.go create mode 100644 tests/server/test_idor_protection/change_config_disable_blocking.json create mode 100644 tests/server/test_idor_protection/env.json create mode 100644 tests/server/test_idor_protection/index.php create mode 100644 tests/server/test_idor_protection/start_config.json create mode 100644 tests/server/test_idor_protection/test.py diff --git a/docs/idor-protection.md b/docs/idor-protection.md new file mode 100644 index 000000000..9bb71f424 --- /dev/null +++ b/docs/idor-protection.md @@ -0,0 +1,139 @@ +# IDOR Protection + +IDOR stands for Insecure Direct Object Reference — it's when one account can access another account's data because a query doesn't properly filter by account. + +If your SaaS has accounts (or organizations, workspaces, teams, ...) and uses a column like `tenant_id` to keep each account's data separate, IDOR protection ensures every SQL query filters on the correct tenant. Zen analyzes queries at runtime and throws an error if a query is missing that filter or uses the wrong tenant ID, catching mistakes like: + +- A `SELECT` that forgets the tenant filter, letting one account read another's orders +- An `UPDATE` or `DELETE` without a tenant filter, letting one account modify another's data +- An `INSERT` that omits the tenant column, creating orphaned or misassigned rows + +Zen catches these at runtime so they surface during development and testing, not in production. See [IDOR vulnerability explained](https://www.aikido.dev/blog/idor-vulnerability-explained) for more background. + +> [!IMPORTANT] +> IDOR protection always throws an exception on violations regardless of block/detect mode. A missing filter is a developer bug, not an external attack. + +## Setup + +### 1. Enable IDOR protection at startup + +```php +\aikido\enable_idor_protection("tenant_id", ["users"]); +``` + +- `tenant_column_name` — the column name that identifies the tenant in your database tables (e.g. `account_id`, `organization_id`, `team_id`). +- `excluded_tables` — tables that Zen should skip IDOR checks for, because rows aren't scoped to a single tenant (e.g. a shared `users` table that stores users across all tenants). + +### 2. Set the tenant ID per request + +Every request must have a tenant ID when IDOR protection is enabled. Call `set_tenant_id` early in your request handler (e.g. in middleware after authentication): + +```php +\aikido\set_tenant_id($user->organizationId); +``` + +> [!IMPORTANT] +> If `set_tenant_id` is not called for a request, Zen will throw an exception when a SQL query is executed. + +### 3. Bypass for specific queries (optional) + +Some queries don't need tenant filtering (e.g. aggregations across all tenants for an admin dashboard). Use `without_idor_protection` to bypass the check for a specific callback: + +```php +$result = \aikido\without_idor_protection(function() use ($pdo) { + return $pdo->query("SELECT count(*) FROM agents WHERE status = 'running'"); +}); +``` + +## Troubleshooting + +
+Missing tenant filter + +``` +Zen IDOR protection: query on table 'orders' is missing a filter on column 'tenant_id' +``` + +This means you have a query like `SELECT * FROM orders WHERE status = 'active'` that doesn't filter on `tenant_id`. The same check applies to `UPDATE` and `DELETE` queries. + +
+ +
+Wrong tenant ID value + +``` +Zen IDOR protection: query on table 'orders' filters 'tenant_id' with value '456' but tenant ID is '123' +``` + +This means the query filters on `tenant_id`, but the value doesn't match the tenant ID set via `set_tenant_id`. + +
+ +
+Missing tenant column in INSERT + +``` +Zen IDOR protection: INSERT on table 'orders' is missing column 'tenant_id' +``` + +This means an `INSERT` statement doesn't include the tenant column. Every INSERT must include the tenant column with the correct tenant ID value. + +
+ +
+Wrong tenant ID in INSERT + +``` +Zen IDOR protection: INSERT on table 'orders' sets 'tenant_id' to '456' but tenant ID is '123' +``` + +This means the INSERT includes the tenant column, but the value doesn't match the tenant ID set via `set_tenant_id`. + +
+ +
+Missing set_tenant_id call + +``` +Zen IDOR protection: setTenantId() was not called for this request. Every request must have a tenant ID when IDOR protection is enabled. +``` + +
+ +## Supported databases + +- MySQL (via `mysqli` and PDO) +- PostgreSQL (via PDO) +- SQLite (via PDO) + +Any ORM or query builder that uses PDO or mysqli under the hood is supported (e.g. Eloquent, Doctrine DBAL). + +## Prepared statements + +Zen supports placeholder resolution for prepared statements executed via `PDOStatement::execute()`: + +```php +// Positional placeholders — values are resolved +$stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = ?"); +$stmt->execute([$tenantId]); + +// Named placeholders — values are resolved +$stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); +$stmt->execute([':tid' => $tenantId]); +``` + +Parameters bound with `bindValue()` or `bindParam()` before calling `execute()` without arguments are also resolved: + +```php +$stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); +$stmt->bindValue(':tid', $tenantId); +$stmt->execute(); +``` + +## Statements that are always allowed + +Zen only checks statements that read or modify row data (`SELECT`, `INSERT`, `UPDATE`, `DELETE`). The following statement types are also recognized and never trigger an IDOR error: + +- DDL — `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, ... +- Session commands — `SET`, `SHOW`, ... +- Transactions — `BEGIN`, `COMMIT`, `ROLLBACK`, ... diff --git a/lib/API.h b/lib/API.h index 95c6518a8..ee2df7ca6 100644 --- a/lib/API.h +++ b/lib/API.h @@ -16,6 +16,7 @@ enum EVENT_ID { EVENT_PRE_SHELL_EXECUTED, EVENT_PRE_PATH_ACCESSED, EVENT_PRE_SQL_QUERY_EXECUTED, + EVENT_ENABLE_IDOR_PROTECTION, MAX_EVENT_ID }; @@ -60,9 +61,15 @@ enum CALLBACK_ID { SQL_QUERY, SQL_DIALECT, + SQL_PARAMS, MODULE, + CONTEXT_TENANT_ID, + CONTEXT_IDOR_DISABLED, + CONTEXT_IDOR_TENANT_COLUMN_NAME, + CONTEXT_IDOR_EXCLUDED_TABLES, + MAX_CALLBACK_ID }; diff --git a/lib/php-extension/Action.cpp b/lib/php-extension/Action.cpp index 13c0976f1..1f0261080 100644 --- a/lib/php-extension/Action.cpp +++ b/lib/php-extension/Action.cpp @@ -77,6 +77,18 @@ bool Action::IsDetection(std::string &event) { return !event.empty(); } +bool Action::IsIdorViolation(std::string &event) { + if (event.empty()) { + return false; + } + try { + json eventJson = json::parse(event); + return eventJson.value("idor_violation", false); + } catch (...) { + return false; + } +} + void Action::Reset() { block = false; type = ""; diff --git a/lib/php-extension/Aikido.cpp b/lib/php-extension/Aikido.cpp index acbe25252..d854b1007 100644 --- a/lib/php-extension/Aikido.cpp +++ b/lib/php-extension/Aikido.cpp @@ -94,6 +94,9 @@ static const zend_function_entry ext_functions[] = { ZEND_NS_FE("aikido", set_token, arginfo_aikido_set_token) ZEND_NS_FE("aikido", set_rate_limit_group, arginfo_aikido_set_rate_limit_group) ZEND_NS_FE("aikido", register_param_matcher, arginfo_aikido_register_param_matcher) + ZEND_NS_FE("aikido", enable_idor_protection, arginfo_aikido_enable_idor_protection) + ZEND_NS_FE("aikido", set_tenant_id, arginfo_aikido_set_tenant_id) + ZEND_NS_FE("aikido", without_idor_protection, arginfo_aikido_without_idor_protection) ZEND_FE_END }; diff --git a/lib/php-extension/GoWrappers.cpp b/lib/php-extension/GoWrappers.cpp index c13149297..7c9034356 100644 --- a/lib/php-extension/GoWrappers.cpp +++ b/lib/php-extension/GoWrappers.cpp @@ -129,6 +129,10 @@ char* GoContextCallback(int callbackId) { ctx = "SQL_DIALECT"; ret = GetEventCacheField(&EventCache::sqlDialect); break; + case SQL_PARAMS: + ctx = "SQL_PARAMS"; + ret = GetEventCacheField(&EventCache::sqlParams); + break; case MODULE: ctx = "MODULE"; ret = GetEventCacheField(&EventCache::moduleName); @@ -145,6 +149,22 @@ char* GoContextCallback(int callbackId) { ctx = "PARAM_MATCHER_REGEX"; ret = GetEventCacheField(&EventCache::paramMatcherRegex); break; + case CONTEXT_TENANT_ID: + ctx = "TENANT_ID"; + ret = requestCache.tenantId; + break; + case CONTEXT_IDOR_DISABLED: + ctx = "IDOR_DISABLED"; + ret = requestCache.idorDisabled ? "1" : ""; + break; + case CONTEXT_IDOR_TENANT_COLUMN_NAME: + ctx = "IDOR_TENANT_COLUMN_NAME"; + ret = GetEventCacheField(&EventCache::idorTenantColumnName); + break; + case CONTEXT_IDOR_EXCLUDED_TABLES: + ctx = "IDOR_EXCLUDED_TABLES"; + ret = GetEventCacheField(&EventCache::idorExcludedTables); + break; } } catch (std::exception& e) { AIKIDO_LOG_DEBUG("Exception in GoContextCallback: %s\n", e.what()); diff --git a/lib/php-extension/Handle.cpp b/lib/php-extension/Handle.cpp index 0be9c9b02..8785c7c1d 100644 --- a/lib/php-extension/Handle.cpp +++ b/lib/php-extension/Handle.cpp @@ -9,6 +9,15 @@ ACTION_STATUS aikido_process_event(EVENT_ID& eventId, std::string& sink) { std::string outputEvent; requestProcessor.SendEvent(eventId, outputEvent); + if (outputEvent.empty()) { + return CONTINUE; + } + + /* IDOR violations always throw, regardless of blocking mode */ + if (action.IsIdorViolation(outputEvent)) { + return action.Execute(outputEvent); + } + if (action.IsDetection(outputEvent)) { stats[sink].IncrementAttacksDetected(); } diff --git a/lib/php-extension/HandleIdorProtection.cpp b/lib/php-extension/HandleIdorProtection.cpp new file mode 100644 index 000000000..57c5ede45 --- /dev/null +++ b/lib/php-extension/HandleIdorProtection.cpp @@ -0,0 +1,99 @@ +#include "Includes.h" + +ZEND_FUNCTION(enable_idor_protection) { + ScopedTimer scopedTimer("enable_idor_protection", "aikido_op"); + ScopedEventContext scopedContext; + + if (IsAikidoDisabledOrBypassed()) { + RETURN_BOOL(false); + } + + char *tenantColumnName = nullptr; + size_t tenantColumnNameLength = 0; + zval *excludedTablesZval = nullptr; + + ZEND_PARSE_PARAMETERS_START(1, 2) + Z_PARAM_STRING(tenantColumnName, tenantColumnNameLength) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(excludedTablesZval) + ZEND_PARSE_PARAMETERS_END(); + + if (!tenantColumnName || tenantColumnNameLength == 0) { + AIKIDO_LOG_ERROR("enable_idor_protection: tenant_column_name is null or empty!\n"); + RETURN_BOOL(false); + } + + eventCacheStack.Top().idorTenantColumnName = std::string(tenantColumnName, tenantColumnNameLength); + + json excludedTablesJson = json::array(); + if (excludedTablesZval) { + HashTable *ht = Z_ARRVAL_P(excludedTablesZval); + zval *entry; + ZEND_HASH_FOREACH_VAL(ht, entry) { + if (Z_TYPE_P(entry) == IS_STRING) { + excludedTablesJson.push_back(std::string(Z_STRVAL_P(entry), Z_STRLEN_P(entry))); + } + } ZEND_HASH_FOREACH_END(); + } + eventCacheStack.Top().idorExcludedTables = excludedTablesJson.dump(); + + try { + std::string outputEvent; + requestProcessor.SendEvent(EVENT_ENABLE_IDOR_PROTECTION, outputEvent); + action.Execute(outputEvent); + } catch (const std::exception &e) { + AIKIDO_LOG_ERROR("Exception encountered in enable_idor_protection: %s\n", e.what()); + RETURN_BOOL(false); + } + + AIKIDO_LOG_INFO("Enabled IDOR protection with tenant column '%s'\n", tenantColumnName); + RETURN_BOOL(true); +} + +ZEND_FUNCTION(set_tenant_id) { + if (IsAikidoDisabledOrBypassed()) { + return; + } + + char *id = nullptr; + size_t idLength = 0; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_STRING(id, idLength) + ZEND_PARSE_PARAMETERS_END(); + + if (!id || idLength == 0) { + AIKIDO_LOG_ERROR("set_tenant_id: id is null or empty!\n"); + return; + } + + requestCache.tenantId = std::string(id, idLength); + AIKIDO_LOG_DEBUG("Set tenant ID to %s\n", requestCache.tenantId.c_str()); +} + +ZEND_FUNCTION(without_idor_protection) { + zend_fcall_info fci; + zend_fcall_info_cache fci_cache; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_FUNC(fci, fci_cache) + ZEND_PARSE_PARAMETERS_END(); + + requestCache.idorDisabled = true; + + zval retval; + ZVAL_UNDEF(&retval); + fci.retval = &retval; + + zend_result result = zend_call_function(&fci, &fci_cache); + + requestCache.idorDisabled = false; + + if (result == SUCCESS && !EG(exception)) { + if (!Z_ISUNDEF(retval)) { + ZVAL_COPY_VALUE(return_value, &retval); + } + } else { + zval_ptr_dtor(&retval); + } +} diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp index 346a16ec4..3cf8ba0b2 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -52,6 +52,133 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdo_exec) { eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(pdo_object); } +/* + bindParam() binds a variable by reference — PHP stores this as an IS_REFERENCE + zval wrapping the actual value. Without unwrapping, Z_TYPE_P would return + IS_REFERENCE instead of the real type (IS_STRING, IS_LONG, ...) and the switch + below would fail to extract the value. bindValue() stores values directly, so + no unwrapping is needed for those. +*/ +static bool ZvalToString(zval *entry, std::string &out) { + if (Z_TYPE_P(entry) == IS_REFERENCE) { + entry = Z_REFVAL_P(entry); + } + + switch (Z_TYPE_P(entry)) { + case IS_STRING: + out = std::string(Z_STRVAL_P(entry), Z_STRLEN_P(entry)); + return true; + case IS_LONG: + out = std::to_string(Z_LVAL_P(entry)); + return true; + case IS_DOUBLE: + out = std::to_string(Z_DVAL_P(entry)); + return true; + case IS_TRUE: + out = "1"; + return true; + case IS_FALSE: + out = "0"; + return true; + case IS_NULL: + out = ""; + return true; + default: + return false; + } +} + +/* Unhandled types (arrays, objects) are stored as null to preserve positional indices. */ +static std::string ConvertParamsToJson(zval *params) { + if (!params || Z_TYPE_P(params) != IS_ARRAY) { + return ""; + } + + HashTable *ht = Z_ARRVAL_P(params); + json paramsJson; + + bool isIndexed = true; + zend_string *key; + zend_ulong idx; + ZEND_HASH_FOREACH_KEY(ht, idx, key) { + if (key) { + isIndexed = false; + break; + } + (void)idx; + } ZEND_HASH_FOREACH_END(); + + if (isIndexed) { + paramsJson = json::array(); + } else { + paramsJson = json::object(); + } + + zval *entry; + ZEND_HASH_FOREACH_KEY_VAL(ht, idx, key, entry) { + std::string valueStr; + bool resolved = ZvalToString(entry, valueStr); + + if (isIndexed) { + if (resolved) { + paramsJson.push_back(valueStr); + } else { + paramsJson.push_back(nullptr); + } + } else if (key && resolved) { + paramsJson[std::string(ZSTR_VAL(key), ZSTR_LEN(key))] = valueStr; + } + (void)idx; + } ZEND_HASH_FOREACH_END(); + + return paramsJson.dump(); +} + +/* Fallback for when execute() is called without inline params (bindValue/bindParam). */ +static std::string ConvertBoundParamsToJson(HashTable *bound_params) { + if (!bound_params || zend_hash_num_elements(bound_params) == 0) { + return ""; + } + + bool hasNamed = false; + zend_string *key; + zend_ulong idx; + ZEND_HASH_FOREACH_KEY(bound_params, idx, key) { + if (key) { + hasNamed = true; + break; + } + (void)idx; + } ZEND_HASH_FOREACH_END(); + + json paramsJson; + if (hasNamed) { + paramsJson = json::object(); + } else { + paramsJson = json::array(); + } + + struct pdo_bound_param_data *param; + ZEND_HASH_FOREACH_PTR(bound_params, param) { + std::string valueStr; + bool resolved = ZvalToString(¶m->parameter, valueStr); + + if (hasNamed && param->name) { + if (resolved) { + paramsJson[std::string(ZSTR_VAL(param->name), ZSTR_LEN(param->name))] = valueStr; + } + } else { + if (resolved) { + paramsJson.push_back(valueStr); + } else { + paramsJson.push_back(nullptr); + } + } + } ZEND_HASH_FOREACH_END(); + + return paramsJson.dump(); +} + AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { scopedTimer.SetSink(sink, "sql_op"); @@ -61,13 +188,25 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { } pdo_stmt_t *stmt = Z_PDO_STMT_P(pdo_statement_object); - if (!stmt->dbh) { // object is not initialized + if (!stmt->dbh) { // object is not initialized return; } + zval *inputParams = NULL; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(inputParams) + ZEND_PARSE_PARAMETERS_END(); + eventId = EVENT_PRE_SQL_QUERY_EXECUTED; - eventCacheStack.Top().moduleName = "PDOStatement"; - eventCacheStack.Top().sqlQuery = PHP_GET_CHAR_PTR(stmt->query_string); + eventCacheStack.Top().moduleName = "PDOStatement"; + eventCacheStack.Top().sqlQuery = PHP_GET_CHAR_PTR(stmt->query_string); + + std::string sqlParams = ConvertParamsToJson(inputParams); + if (sqlParams.empty() && stmt->bound_params) { + sqlParams = ConvertBoundParamsToJson(stmt->bound_params); + } + eventCacheStack.Top().sqlParams = sqlParams; #if PHP_VERSION_ID >= 80500 if (!stmt->database_object_handle) { diff --git a/lib/php-extension/include/Action.h b/lib/php-extension/include/Action.h index dbb3c4945..91a0134ab 100644 --- a/lib/php-extension/include/Action.h +++ b/lib/php-extension/include/Action.h @@ -35,6 +35,7 @@ class Action { ACTION_STATUS Execute(std::string &event); bool IsDetection(std::string &event); + bool IsIdorViolation(std::string &event); void Reset(); diff --git a/lib/php-extension/include/Cache.h b/lib/php-extension/include/Cache.h index 869dcb8ec..284a7561d 100644 --- a/lib/php-extension/include/Cache.h +++ b/lib/php-extension/include/Cache.h @@ -9,6 +9,8 @@ class RequestCache { std::string rateLimitGroup; std::string outgoingRequestUrl; std::string outgoingRequestRedirectUrl; + std::string tenantId; + bool idorDisabled = false; RequestCache() = default; void Reset(); @@ -32,10 +34,14 @@ class EventCache { std::string sqlQuery; std::string sqlDialect; + std::string sqlParams; std::string paramMatcherParam; std::string paramMatcherRegex; + std::string idorTenantColumnName; + std::string idorExcludedTables; + EventCache() = default; void Reset(); }; diff --git a/lib/php-extension/include/HandleIdorProtection.h b/lib/php-extension/include/HandleIdorProtection.h new file mode 100644 index 000000000..06e752542 --- /dev/null +++ b/lib/php-extension/include/HandleIdorProtection.h @@ -0,0 +1,18 @@ +#pragma once + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_aikido_enable_idor_protection, 0, 1, _IS_BOOL, 0) + ZEND_ARG_TYPE_INFO(0, tenant_column_name, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, excluded_tables, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_aikido_set_tenant_id, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, id, IS_STRING, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_INFO_EX(arginfo_aikido_without_idor_protection, 0, 0, 1) + ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) +ZEND_END_ARG_INFO() + +ZEND_FUNCTION(enable_idor_protection); +ZEND_FUNCTION(set_tenant_id); +ZEND_FUNCTION(without_idor_protection); diff --git a/lib/php-extension/include/Includes.h b/lib/php-extension/include/Includes.h index ce6e90d5f..3067ece93 100644 --- a/lib/php-extension/include/Includes.h +++ b/lib/php-extension/include/Includes.h @@ -67,4 +67,5 @@ using json = nlohmann::json; #include "HandlePathAccess.h" #include "HandleFileCompilation.h" #include "HandleBypassedIp.h" +#include "HandleIdorProtection.h" #include "HookAst.h" diff --git a/lib/request-processor/context/event_getters.go b/lib/request-processor/context/event_getters.go index 0929666fb..460df7065 100644 --- a/lib/request-processor/context/event_getters.go +++ b/lib/request-processor/context/event_getters.go @@ -43,6 +43,10 @@ func GetSqlDialect() string { return Context.Callback(C.SQL_DIALECT) } +func GetSqlParams() string { + return Context.Callback(C.SQL_PARAMS) +} + func GetModule() string { return Context.Callback(C.MODULE) } @@ -57,6 +61,22 @@ func GetParamMatcher() (string, string) { return param, regex } +func GetTenantId() string { + return Context.Callback(C.CONTEXT_TENANT_ID) +} + +func IsIdorDisabled() bool { + return Context.Callback(C.CONTEXT_IDOR_DISABLED) == "1" +} + +func GetIdorTenantColumnName() string { + return Context.Callback(C.CONTEXT_IDOR_TENANT_COLUMN_NAME) +} + +func GetIdorExcludedTables() string { + return Context.Callback(C.CONTEXT_IDOR_EXCLUDED_TABLES) +} + func getHostNameAndPort(urlCallbackId int, portCallbackId int) (string, uint32) { // urlcallbackid is the type of data we request, eg C.OUTGOING_REQUEST_URL urlStr := Context.Callback(urlCallbackId) urlParsed, err := url.Parse(urlStr) diff --git a/lib/request-processor/handle_enable_idor_protection.go b/lib/request-processor/handle_enable_idor_protection.go new file mode 100644 index 000000000..e1a1b6959 --- /dev/null +++ b/lib/request-processor/handle_enable_idor_protection.go @@ -0,0 +1,28 @@ +package main + +import ( + "encoding/json" + "main/context" + "main/log" + idor "main/vulnerabilities/idor" +) + +func OnEnableIdorProtection() string { + tenantColumnName := context.GetIdorTenantColumnName() + if tenantColumnName == "" { + log.Warn("enable_idor_protection: tenant column name is empty!") + return "" + } + + excludedTablesJson := context.GetIdorExcludedTables() + var excludedTables []string + if excludedTablesJson != "" { + if err := json.Unmarshal([]byte(excludedTablesJson), &excludedTables); err != nil { + log.Warnf("enable_idor_protection: failed to parse excluded tables: %s", err) + excludedTables = []string{} + } + } + + idor.SetIdorConfig(tenantColumnName, excludedTables) + return "" +} diff --git a/lib/request-processor/handle_sql_queries.go b/lib/request-processor/handle_sql_queries.go index a92f3bddf..98dc27dcf 100644 --- a/lib/request-processor/handle_sql_queries.go +++ b/lib/request-processor/handle_sql_queries.go @@ -4,6 +4,7 @@ import ( "main/attack" "main/context" "main/log" + idor "main/vulnerabilities/idor" sql_injection "main/vulnerabilities/sql-injection" ) @@ -24,5 +25,15 @@ func OnPreSqlQueryExecuted() string { if res != nil { return attack.ReportAttackDetected(res) } + + if idor.IsIdorEnabled() && !context.IsIdorDisabled() { + tenantId := context.GetTenantId() + sqlParams := context.GetSqlParams() + idorResult := idor.CheckForIdorViolation(query, dialect, tenantId, sqlParams) + if idorResult != "" { + return idorResult + } + } + return "" } diff --git a/lib/request-processor/main.go b/lib/request-processor/main.go index 4b820ca5d..15a0c6694 100644 --- a/lib/request-processor/main.go +++ b/lib/request-processor/main.go @@ -30,6 +30,7 @@ var eventHandlers = map[int]HandlerFunction{ C.EVENT_PRE_SHELL_EXECUTED: OnPreShellExecuted, C.EVENT_PRE_PATH_ACCESSED: OnPrePathAccessed, C.EVENT_PRE_SQL_QUERY_EXECUTED: OnPreSqlQueryExecuted, + C.EVENT_ENABLE_IDOR_PROTECTION: OnEnableIdorProtection, } func initializeServer(server *ServerData) { diff --git a/lib/request-processor/utils/utils.go b/lib/request-processor/utils/utils.go index ea6887ad1..799b0c83a 100644 --- a/lib/request-processor/utils/utils.go +++ b/lib/request-processor/utils/utils.go @@ -440,7 +440,7 @@ func GetSqlDialectFromString(dialect string) int { return int(MySQL) case "sqlite": return int(SQLite) - case "postgres": + case "postgres", "pgsql": return int(PostgreSQL) default: return int(Generic) diff --git a/lib/request-processor/vulnerabilities/idor/idor.go b/lib/request-processor/vulnerabilities/idor/idor.go new file mode 100644 index 000000000..ea8842d7d --- /dev/null +++ b/lib/request-processor/vulnerabilities/idor/idor.go @@ -0,0 +1,275 @@ +package idor + +import ( + "encoding/json" + "fmt" + "main/attack" + "main/log" + "main/utils" + zen_internals "main/vulnerabilities/zen-internals" +) + +type IdorConfig struct { + TenantColumnName string + ExcludedTables []string +} + +var idorConfig *IdorConfig + +func SetIdorConfig(tenantColumnName string, excludedTables []string) { + idorConfig = &IdorConfig{ + TenantColumnName: tenantColumnName, + ExcludedTables: excludedTables, + } + log.Infof("IDOR protection enabled with tenant column '%s' and %d excluded tables", tenantColumnName, len(excludedTables)) +} + +func IsIdorEnabled() bool { + return idorConfig != nil +} + +type TableRef struct { + Name string `json:"name"` + Alias *string `json:"alias,omitempty"` +} + +type FilterColumn struct { + Table *string `json:"table,omitempty"` + Column string `json:"column"` + Value string `json:"value"` + PlaceholderNumber *int `json:"placeholder_number,omitempty"` +} + +type InsertColumn struct { + Column string `json:"column"` + Value string `json:"value"` + PlaceholderNumber *int `json:"placeholder_number,omitempty"` +} + +type SqlQueryResult struct { + Kind string `json:"kind"` + Tables []TableRef `json:"tables"` + Filters []FilterColumn `json:"filters"` + InsertColumns [][]InsertColumn `json:"insert_columns,omitempty"` +} + +type AnalysisError struct { + Error string `json:"error"` +} + +func CheckForIdorViolation(query string, dialect string, tenantId string, sqlParams string) string { + if idorConfig == nil { + return "" + } + + if tenantId == "" { + return buildIdorViolationAction( + "Zen IDOR protection: setTenantId() was not called for this request. Every request must have a tenant ID when IDOR protection is enabled.", + ) + } + + dialectId := utils.GetSqlDialectFromString(dialect) + resultJson := zen_internals.IdorAnalyzeSql(query, dialectId) + if resultJson == "" { + return buildIdorViolationAction("Zen IDOR protection: failed to analyze SQL query") + } + + var analysisError AnalysisError + if err := json.Unmarshal([]byte(resultJson), &analysisError); err == nil && analysisError.Error != "" { + return buildIdorViolationAction(fmt.Sprintf("Zen IDOR protection: %s", analysisError.Error)) + } + + var queryResults []SqlQueryResult + if err := json.Unmarshal([]byte(resultJson), &queryResults); err != nil { + log.Warnf("Failed to parse IDOR analysis result: %s", err) + return buildIdorViolationAction("Zen IDOR protection: failed to parse SQL analysis result") + } + + params := parseSqlParams(sqlParams) + + for _, queryResult := range queryResults { + switch queryResult.Kind { + case "insert": + if msg := checkInsert(queryResult, tenantId, params); msg != "" { + return buildIdorViolationAction(msg) + } + case "select", "update", "delete": + if msg := checkWhereFilters(queryResult, tenantId, params); msg != "" { + return buildIdorViolationAction(msg) + } + } + // DDL, transactions, etc. are allowed + } + + return "" +} + +func checkWhereFilters(queryResult SqlQueryResult, tenantId string, params *SqlParams) string { + for _, table := range queryResult.Tables { + if isExcludedTable(table.Name) { + continue + } + + tenantFilter := findTenantFilter(queryResult, table) + if tenantFilter == nil { + return fmt.Sprintf( + "Zen IDOR protection: query on table '%s' is missing a filter on column '%s'", + table.Name, idorConfig.TenantColumnName, + ) + } + + resolvedValue := resolveValue(tenantFilter.Value, tenantFilter.PlaceholderNumber, params) + if resolvedValue != "" && resolvedValue != tenantId { + return fmt.Sprintf( + "Zen IDOR protection: query on table '%s' filters '%s' with value '%s' but tenant ID is '%s'", + table.Name, idorConfig.TenantColumnName, resolvedValue, tenantId, + ) + } + } + + return "" +} + +func checkInsert(queryResult SqlQueryResult, tenantId string, params *SqlParams) string { + for _, table := range queryResult.Tables { + if isExcludedTable(table.Name) { + continue + } + + if queryResult.InsertColumns == nil { + // INSERT ... SELECT without explicit columns + return fmt.Sprintf( + "Zen IDOR protection: INSERT on table '%s' is missing column '%s'", + table.Name, idorConfig.TenantColumnName, + ) + } + + for _, row := range queryResult.InsertColumns { + tenantCol := findTenantColumn(row) + if tenantCol == nil { + return fmt.Sprintf( + "Zen IDOR protection: INSERT on table '%s' is missing column '%s'", + table.Name, idorConfig.TenantColumnName, + ) + } + + resolvedValue := resolveValue(tenantCol.Value, tenantCol.PlaceholderNumber, params) + if resolvedValue != "" && resolvedValue != tenantId { + return fmt.Sprintf( + "Zen IDOR protection: INSERT on table '%s' sets '%s' to '%s' but tenant ID is '%s'", + table.Name, idorConfig.TenantColumnName, resolvedValue, tenantId, + ) + } + } + } + + return "" +} + +func findTenantFilter(queryResult SqlQueryResult, table TableRef) *FilterColumn { + for i, f := range queryResult.Filters { + if f.Column != idorConfig.TenantColumnName { + continue + } + + if f.Table != nil { + // Qualified column (e.g. u.tenant_id) — match against table name or alias + if *f.Table == table.Name || (table.Alias != nil && *f.Table == *table.Alias) { + return &queryResult.Filters[i] + } + } else { + // Unqualified column — only safe to match in single-table queries + if len(queryResult.Tables) == 1 { + return &queryResult.Filters[i] + } + } + } + + return nil +} + +func findTenantColumn(row []InsertColumn) *InsertColumn { + for i, c := range row { + if c.Column == idorConfig.TenantColumnName { + return &row[i] + } + } + return nil +} + +func isExcludedTable(tableName string) bool { + for _, excluded := range idorConfig.ExcludedTables { + if excluded == tableName { + return true + } + } + return false +} + +type SqlParams struct { + Positional []interface{} + Named map[string]string +} + +func parseSqlParams(sqlParamsJson string) *SqlParams { + if sqlParamsJson == "" { + return nil + } + + var positional []interface{} + if err := json.Unmarshal([]byte(sqlParamsJson), &positional); err == nil { + return &SqlParams{Positional: positional} + } + + var named map[string]string + if err := json.Unmarshal([]byte(sqlParamsJson), &named); err == nil { + return &SqlParams{Named: named} + } + + return nil +} + +// Returns empty string for unresolvable placeholders (skips value validation). +func resolveValue(value string, placeholderNumber *int, params *SqlParams) string { + if params != nil { + if placeholderNumber != nil && params.Positional != nil { + idx := *placeholderNumber + if idx >= 0 && idx < len(params.Positional) { + if str, ok := params.Positional[idx].(string); ok { + return str + } + return "" + } + return "" + } + + if params.Named != nil && len(value) > 0 && value[0] == ':' { + if resolved, ok := params.Named[value]; ok { + return resolved + } + return "" + } + } + + if placeholderNumber != nil { + return "" + } + if len(value) > 0 && (value[0] == '$' || value[0] == ':' || value == "?") { + return "" + } + return value +} + +func buildIdorViolationAction(message string) string { + actionMap := map[string]interface{}{ + "action": "throw", + "message": message, + "code": 500, + "idor_violation": true, + } + actionJson, err := json.Marshal(actionMap) + if err != nil { + return attack.GetThrowAction(message, 500) + } + return string(actionJson) +} diff --git a/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go b/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go index e305b11eb..9815520bc 100644 --- a/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go +++ b/lib/request-processor/vulnerabilities/zen-internals/zen_internals.go @@ -7,6 +7,8 @@ package zen_internals typedef int (*detect_sql_injection_func)(const char*, size_t, const char*, size_t, int); typedef int (*detect_shell_injection_func)(const char*, const char*); +typedef char* (*idor_analyze_sql_func)(const char*, size_t, int); +typedef void (*free_string_func)(char*); int call_detect_shell_injection(detect_shell_injection_func func, const char* command, const char* user_input) { return func(command, user_input); @@ -18,6 +20,16 @@ int call_detect_sql_injection(detect_sql_injection_func func, int sql_dialect) { return func(query, query_len, input, input_len, sql_dialect); } + +char* call_idor_analyze_sql(idor_analyze_sql_func func, + const char* query, size_t query_len, + int dialect) { + return func(query, query_len, dialect); +} + +void call_free_string(free_string_func func, char* ptr) { + func(ptr); +} */ import "C" import ( @@ -31,6 +43,8 @@ import ( var ( handle unsafe.Pointer detectSqlInjection C.detect_sql_injection_func + idorAnalyzeSql C.idor_analyze_sql_func + freeString C.free_string_func ) func Init() bool { @@ -53,12 +67,37 @@ func Init() bool { } detectSqlInjection = (C.detect_sql_injection_func)(vDetectSqlInjection) + + idorAnalyzeSqlFnName := C.CString("idor_analyze_sql_ffi") + defer C.free(unsafe.Pointer(idorAnalyzeSqlFnName)) + + vIdorAnalyzeSql := C.dlsym(handle, idorAnalyzeSqlFnName) + if vIdorAnalyzeSql == nil { + log.Error("Failed to load idor_analyze_sql_ffi function from zen-internals library!") + return false + } + + idorAnalyzeSql = (C.idor_analyze_sql_func)(vIdorAnalyzeSql) + + freeStringFnName := C.CString("free_string") + defer C.free(unsafe.Pointer(freeStringFnName)) + + vFreeString := C.dlsym(handle, freeStringFnName) + if vFreeString == nil { + log.Error("Failed to load free_string function from zen-internals library!") + return false + } + + freeString = (C.free_string_func)(vFreeString) + log.Debugf("Loaded zen-internals library!") return true } func Uninit() { detectSqlInjection = nil + idorAnalyzeSql = nil + freeString = nil if handle != nil { C.dlclose(handle) @@ -89,3 +128,23 @@ func DetectSQLInjection(query string, user_input string, dialect int) int { log.Debugf("DetectSqlInjection(\"%s\", \"%s\", %d) -> %d", query, user_input, dialect, result) return result } + +func IdorAnalyzeSql(query string, dialect int) string { + if idorAnalyzeSql == nil || freeString == nil { + return "" + } + + cQuery := C.CString(query) + defer C.free(unsafe.Pointer(cQuery)) + queryLen := C.size_t(len(query)) + + resultPtr := C.call_idor_analyze_sql(idorAnalyzeSql, cQuery, queryLen, C.int(dialect)) + if resultPtr == nil { + return "" + } + + result := C.GoString(resultPtr) + C.call_free_string(freeString, resultPtr) + + return result +} diff --git a/tests/server/test_idor_protection/change_config_disable_blocking.json b/tests/server/test_idor_protection/change_config_disable_blocking.json new file mode 100644 index 000000000..ce7181fd1 --- /dev/null +++ b/tests/server/test_idor_protection/change_config_disable_blocking.json @@ -0,0 +1,10 @@ +{ + "success": true, + "serviceId": 1, + "heartbeatIntervalInMS": 600000, + "endpoints": [], + "blockedUserIds": [], + "allowedIPAddresses": [], + "receivedAnyStats": true, + "block": false +} diff --git a/tests/server/test_idor_protection/env.json b/tests/server/test_idor_protection/env.json new file mode 100644 index 000000000..ae9ce76d0 --- /dev/null +++ b/tests/server/test_idor_protection/env.json @@ -0,0 +1,3 @@ +{ + "AIKIDO_BLOCK": "1" +} diff --git a/tests/server/test_idor_protection/index.php b/tests/server/test_idor_protection/index.php new file mode 100644 index 000000000..a1ae3327b --- /dev/null +++ b/tests/server/test_idor_protection/index.php @@ -0,0 +1,160 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $pdo->exec("CREATE TABLE IF NOT EXISTS orders (id INTEGER PRIMARY KEY, name TEXT, tenant_id TEXT)"); + $pdo->exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT, tenant_id TEXT)"); + + $action = $data['action'] ?? ''; + + switch ($action) { + case 'select_without_filter': + $pdo->query("SELECT * FROM orders"); + break; + + case 'select_with_correct_filter': + $pdo->query("SELECT * FROM orders WHERE tenant_id = '" . $data['tenantId'] . "'"); + break; + + case 'select_with_wrong_filter': + $pdo->query("SELECT * FROM orders WHERE tenant_id = 'wrong_tenant'"); + break; + + case 'select_excluded_table': + $pdo->query("SELECT * FROM users"); + break; + + case 'insert_without_tenant_column': + $pdo->exec("INSERT INTO orders (name) VALUES ('Widget')"); + break; + + case 'insert_with_correct_tenant': + $pdo->exec("INSERT INTO orders (name, tenant_id) VALUES ('Widget', '" . $data['tenantId'] . "')"); + break; + + case 'insert_with_wrong_tenant': + $pdo->exec("INSERT INTO orders (name, tenant_id) VALUES ('Widget', 'wrong_tenant')"); + break; + + case 'update_without_filter': + $pdo->exec("UPDATE orders SET name = 'Updated'"); + break; + + case 'update_with_correct_filter': + $pdo->exec("UPDATE orders SET name = 'Updated' WHERE tenant_id = '" . $data['tenantId'] . "'"); + break; + + case 'delete_without_filter': + $pdo->exec("DELETE FROM orders"); + break; + + case 'delete_with_correct_filter': + $pdo->exec("DELETE FROM orders WHERE tenant_id = '" . $data['tenantId'] . "'"); + break; + + case 'without_idor_protection': + \aikido\without_idor_protection(function() use ($pdo) { + $pdo->query("SELECT * FROM orders"); + }); + break; + + case 'ddl_statement': + $pdo->exec("CREATE TABLE IF NOT EXISTS temp_table (id INTEGER)"); + break; + + case 'transaction': + $pdo->exec("BEGIN"); + $pdo->exec("COMMIT"); + break; + + case 'prepared_positional_correct': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = ?"); + $stmt->execute([$data['tenantId']]); + break; + + case 'prepared_positional_wrong': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = ?"); + $stmt->execute(['wrong_tenant']); + break; + + case 'prepared_named_correct': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $stmt->execute([':tid' => $data['tenantId']]); + break; + + case 'prepared_named_wrong': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $stmt->execute([':tid' => 'wrong_tenant']); + break; + + case 'prepared_insert_correct': + $stmt = $pdo->prepare("INSERT INTO orders (name, tenant_id) VALUES (?, ?)"); + $stmt->execute(['Widget', $data['tenantId']]); + break; + + case 'prepared_insert_wrong': + $stmt = $pdo->prepare("INSERT INTO orders (name, tenant_id) VALUES (?, ?)"); + $stmt->execute(['Widget', 'wrong_tenant']); + break; + + case 'bind_value_correct': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $stmt->bindValue(':tid', $data['tenantId']); + $stmt->execute(); + break; + + case 'bind_value_wrong': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $stmt->bindValue(':tid', 'wrong_tenant'); + $stmt->execute(); + break; + + case 'bind_param_correct': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $tid = $data['tenantId']; + $stmt->bindParam(':tid', $tid); + $stmt->execute(); + break; + + case 'bind_param_wrong': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = :tid"); + $tid = 'wrong_tenant'; + $stmt->bindParam(':tid', $tid); + $stmt->execute(); + break; + + case 'bind_value_positional_correct': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = ?"); + $stmt->bindValue(1, $data['tenantId']); + $stmt->execute(); + break; + + case 'bind_value_positional_wrong': + $stmt = $pdo->prepare("SELECT * FROM orders WHERE tenant_id = ?"); + $stmt->bindValue(1, 'wrong_tenant'); + $stmt->execute(); + break; + + default: + echo "Unknown action"; + break; + } + + echo "OK"; +} catch (\Exception $e) { + http_response_code(500); + echo $e->getMessage(); +} + +?> diff --git a/tests/server/test_idor_protection/start_config.json b/tests/server/test_idor_protection/start_config.json new file mode 100644 index 000000000..430d31127 --- /dev/null +++ b/tests/server/test_idor_protection/start_config.json @@ -0,0 +1,10 @@ +{ + "success": true, + "serviceId": 1, + "heartbeatIntervalInMS": 600000, + "endpoints": [], + "blockedUserIds": [], + "allowedIPAddresses": [], + "receivedAnyStats": true, + "block": true +} diff --git a/tests/server/test_idor_protection/test.py b/tests/server/test_idor_protection/test.py new file mode 100644 index 000000000..f0f31b9f7 --- /dev/null +++ b/tests/server/test_idor_protection/test.py @@ -0,0 +1,111 @@ +import requests +import time +import sys +from testlib import * + +def expect_blocked(action, tenant_id="org_123"): + data = {"action": action, "tenantId": tenant_id} + response = php_server_post("/", data) + assert_response_code_is(response, 500) + assert_response_body_contains(response, "Zen IDOR protection") + return response + +def expect_allowed(action, tenant_id="org_123"): + data = {"action": action, "tenantId": tenant_id} + response = php_server_post("/", data) + assert_response_code_is(response, 200) + assert_response_body_contains(response, "OK") + return response + +def run_test(): + # SELECT without tenant filter should be blocked + expect_blocked("select_without_filter") + + # SELECT with correct tenant filter should be allowed + expect_allowed("select_with_correct_filter") + + # SELECT with wrong tenant ID value should be blocked + expect_blocked("select_with_wrong_filter") + + # SELECT on excluded table should be allowed + expect_allowed("select_excluded_table") + + # INSERT without tenant column should be blocked + expect_blocked("insert_without_tenant_column") + + # INSERT with correct tenant value should be allowed + expect_allowed("insert_with_correct_tenant") + + # INSERT with wrong tenant value should be blocked + expect_blocked("insert_with_wrong_tenant") + + # UPDATE without tenant filter should be blocked + expect_blocked("update_without_filter") + + # UPDATE with correct tenant filter should be allowed + expect_allowed("update_with_correct_filter") + + # DELETE without tenant filter should be blocked + expect_blocked("delete_without_filter") + + # DELETE with correct tenant filter should be allowed + expect_allowed("delete_with_correct_filter") + + # without_idor_protection callback should bypass check + expect_allowed("without_idor_protection") + + # Missing set_tenant_id should be blocked + response = php_server_post("/", {"action": "select_with_correct_filter"}) + assert_response_code_is(response, 500) + assert_response_body_contains(response, "setTenantId() was not called") + + # DDL statements should be allowed + expect_allowed("ddl_statement") + + # Transaction statements should be allowed + expect_allowed("transaction") + + # Prepared statement with correct positional param should be allowed + expect_allowed("prepared_positional_correct") + + # Prepared statement with wrong positional param should be blocked + expect_blocked("prepared_positional_wrong") + + # Prepared statement with correct named param should be allowed + expect_allowed("prepared_named_correct") + + # Prepared statement with wrong named param should be blocked + expect_blocked("prepared_named_wrong") + + # Prepared INSERT with correct param should be allowed + expect_allowed("prepared_insert_correct") + + # Prepared INSERT with wrong param should be blocked + expect_blocked("prepared_insert_wrong") + + # bindValue with correct value should be allowed + expect_allowed("bind_value_correct") + + # bindValue with wrong value should be blocked + expect_blocked("bind_value_wrong") + + # bindParam with correct value should be allowed + expect_allowed("bind_param_correct") + + # bindParam with wrong value should be blocked + expect_blocked("bind_param_wrong") + + # bindValue positional with correct value should be allowed + expect_allowed("bind_value_positional_correct") + + # bindValue positional with wrong value should be blocked + expect_blocked("bind_value_positional_wrong") + + # IDOR violations should throw even in detect-only mode (block=false) + apply_config("change_config_disable_blocking.json") + expect_blocked("select_without_filter") + expect_blocked("insert_without_tenant_column") + +if __name__ == "__main__": + load_test_args() + run_test() From d1413ed3388b4ebc56fca184e746ca11e916e8e9 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 11:57:49 +0100 Subject: [PATCH 2/9] Fix compile --- lib/php-extension/HandleQueries.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp index 3cf8ba0b2..e679199a7 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -158,8 +158,9 @@ static std::string ConvertBoundParamsToJson(HashTable *bound_params) { paramsJson = json::array(); } - struct pdo_bound_param_data *param; - ZEND_HASH_FOREACH_PTR(bound_params, param) { + zval *bound_zval; + ZEND_HASH_FOREACH_VAL(bound_params, bound_zval) { + auto *param = (struct pdo_bound_param_data *)Z_PTR_P(bound_zval); std::string valueStr; bool resolved = ZvalToString(¶m->parameter, valueStr); From ef22c295b638f2cb0c64687580a3e461697e7dcb Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:06:45 +0100 Subject: [PATCH 3/9] Add HandleIdorProtection.cpp --- lib/php-extension/config.m4 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/php-extension/config.m4 b/lib/php-extension/config.m4 index 7a769887c..cca9a5244 100644 --- a/lib/php-extension/config.m4 +++ b/lib/php-extension/config.m4 @@ -90,5 +90,5 @@ if test "$PHP_AIKIDO" != "no"; then dnl In case of no dependencies AC_DEFINE(HAVE_AIKIDO, 1, [ Have aikido support ]) - PHP_NEW_EXTENSION(aikido, Aikido.cpp GoWrappers.cpp Packages.cpp PhpWrappers.cpp PhpLifecycle.cpp Stats.cpp Server.cpp Environment.cpp RequestProcessor.cpp Agent.cpp Hooks.cpp HookAst.cpp Utils.cpp Handle.cpp HandleUrls.cpp HandleSetToken.cpp HandleRegisterParamMatcher.cpp HandleUsers.cpp HandleShouldBlockRequest.cpp HandleBypassedIp.cpp HandleRateLimitGroup.cpp Cache.cpp HandleFileCompilation.cpp HandleShellExecution.cpp Log.cpp HandleQueries.cpp HandlePathAccess.cpp Action.cpp, $ext_shared) + PHP_NEW_EXTENSION(aikido, Aikido.cpp GoWrappers.cpp Packages.cpp PhpWrappers.cpp PhpLifecycle.cpp Stats.cpp Server.cpp Environment.cpp RequestProcessor.cpp Agent.cpp Hooks.cpp HookAst.cpp Utils.cpp Handle.cpp HandleUrls.cpp HandleSetToken.cpp HandleRegisterParamMatcher.cpp HandleUsers.cpp HandleShouldBlockRequest.cpp HandleBypassedIp.cpp HandleRateLimitGroup.cpp Cache.cpp HandleFileCompilation.cpp HandleShellExecution.cpp Log.cpp HandleQueries.cpp HandlePathAccess.cpp HandleIdorProtection.cpp Action.cpp, $ext_shared) fi From 55c687163bae1c2ddbe497ceabb374ab3e4bdd0c Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:12:27 +0100 Subject: [PATCH 4/9] Fix zend_result type --- lib/php-extension/include/PhpWrappers.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/php-extension/include/PhpWrappers.h b/lib/php-extension/include/PhpWrappers.h index 2d35c2da1..7e665f7d6 100644 --- a/lib/php-extension/include/PhpWrappers.h +++ b/lib/php-extension/include/PhpWrappers.h @@ -1,5 +1,9 @@ #pragma once +#if PHP_VERSION_ID < 80000 +typedef ZEND_RESULT_CODE zend_result; +#endif + #if PHP_VERSION_ID >= 80100 #define PHP_GET_CHAR_PTR(x) (ZSTR_VAL(x)) #else From 43cacbfa826cba066abc0bd0f7758106b6bb3b98 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:18:05 +0100 Subject: [PATCH 5/9] Cast to zend_result --- lib/php-extension/HandleIdorProtection.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/php-extension/HandleIdorProtection.cpp b/lib/php-extension/HandleIdorProtection.cpp index 57c5ede45..e5e1a6df7 100644 --- a/lib/php-extension/HandleIdorProtection.cpp +++ b/lib/php-extension/HandleIdorProtection.cpp @@ -85,7 +85,7 @@ ZEND_FUNCTION(without_idor_protection) { ZVAL_UNDEF(&retval); fci.retval = &retval; - zend_result result = zend_call_function(&fci, &fci_cache); + zend_result result = (zend_result)zend_call_function(&fci, &fci_cache); requestCache.idorDisabled = false; From 975ac92d56ce94d8cacc5ae409c236691ede99ff Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:27:53 +0100 Subject: [PATCH 6/9] Fix SQL injection test --- lib/php-extension/HandleQueries.cpp | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp index e679199a7..e21dee24d 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -193,31 +193,31 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { return; } - zval *inputParams = NULL; - ZEND_PARSE_PARAMETERS_START(0, 1) - Z_PARAM_OPTIONAL - Z_PARAM_ARRAY(inputParams) - ZEND_PARSE_PARAMETERS_END(); - eventId = EVENT_PRE_SQL_QUERY_EXECUTED; eventCacheStack.Top().moduleName = "PDOStatement"; eventCacheStack.Top().sqlQuery = PHP_GET_CHAR_PTR(stmt->query_string); - std::string sqlParams = ConvertParamsToJson(inputParams); - if (sqlParams.empty() && stmt->bound_params) { - sqlParams = ConvertBoundParamsToJson(stmt->bound_params); - } - eventCacheStack.Top().sqlParams = sqlParams; - #if PHP_VERSION_ID >= 80500 if (!stmt->database_object_handle) { eventCacheStack.Top().sqlDialect = "unknown"; - return; + } else { + eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(stmt->database_object_handle); } - eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(stmt->database_object_handle); #else eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(&stmt->database_object_handle); #endif + + zval *inputParams = NULL; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(inputParams) + ZEND_PARSE_PARAMETERS_END(); + + std::string sqlParams = ConvertParamsToJson(inputParams); + if (sqlParams.empty() && stmt->bound_params) { + sqlParams = ConvertBoundParamsToJson(stmt->bound_params); + } + eventCacheStack.Top().sqlParams = sqlParams; } zend_class_entry* helper_load_mysqli_link_class_entry() { From d8a19d4ad0b5f084bb6960e5116be787fe6695f7 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:47:40 +0100 Subject: [PATCH 7/9] Handle error when converting invalid UTF-8 bytes to JSON --- lib/php-extension/HandleQueries.cpp | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp index e21dee24d..bde63acac 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -207,17 +207,21 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(&stmt->database_object_handle); #endif - zval *inputParams = NULL; - ZEND_PARSE_PARAMETERS_START(0, 1) - Z_PARAM_OPTIONAL - Z_PARAM_ARRAY(inputParams) - ZEND_PARSE_PARAMETERS_END(); - - std::string sqlParams = ConvertParamsToJson(inputParams); - if (sqlParams.empty() && stmt->bound_params) { - sqlParams = ConvertBoundParamsToJson(stmt->bound_params); + try { + zval *inputParams = NULL; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(inputParams) + ZEND_PARSE_PARAMETERS_END(); + + std::string sqlParams = ConvertParamsToJson(inputParams); + if (sqlParams.empty() && stmt->bound_params) { + sqlParams = ConvertBoundParamsToJson(stmt->bound_params); + } + eventCacheStack.Top().sqlParams = sqlParams; + } catch (const std::exception &e) { + AIKIDO_LOG_DEBUG("Failed to extract SQL params: %s\n", e.what()); } - eventCacheStack.Top().sqlParams = sqlParams; } zend_class_entry* helper_load_mysqli_link_class_entry() { From d8031c85b2bfccf8b4f16c644847fa0fc9d7c665 Mon Sep 17 00:00:00 2001 From: Hans Ott Date: Fri, 13 Feb 2026 12:49:49 +0100 Subject: [PATCH 8/9] Use json::error_handler_t::replace instead of try/catch --- lib/php-extension/HandleQueries.cpp | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/php-extension/HandleQueries.cpp b/lib/php-extension/HandleQueries.cpp index bde63acac..bd638eefe 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -131,7 +131,7 @@ static std::string ConvertParamsToJson(zval *params) { (void)idx; } ZEND_HASH_FOREACH_END(); - return paramsJson.dump(); + return paramsJson.dump(-1, ' ', false, json::error_handler_t::replace); } /* Fallback for when execute() is called without inline params (bindValue/bindParam). */ @@ -177,7 +177,7 @@ static std::string ConvertBoundParamsToJson(HashTable *bound_params) { } } ZEND_HASH_FOREACH_END(); - return paramsJson.dump(); + return paramsJson.dump(-1, ' ', false, json::error_handler_t::replace); } AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { @@ -207,21 +207,17 @@ AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { eventCacheStack.Top().sqlDialect = GetSqlDialectFromPdo(&stmt->database_object_handle); #endif - try { - zval *inputParams = NULL; - ZEND_PARSE_PARAMETERS_START(0, 1) - Z_PARAM_OPTIONAL - Z_PARAM_ARRAY(inputParams) - ZEND_PARSE_PARAMETERS_END(); + zval *inputParams = NULL; + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_ARRAY(inputParams) + ZEND_PARSE_PARAMETERS_END(); - std::string sqlParams = ConvertParamsToJson(inputParams); - if (sqlParams.empty() && stmt->bound_params) { - sqlParams = ConvertBoundParamsToJson(stmt->bound_params); - } - eventCacheStack.Top().sqlParams = sqlParams; - } catch (const std::exception &e) { - AIKIDO_LOG_DEBUG("Failed to extract SQL params: %s\n", e.what()); + std::string sqlParams = ConvertParamsToJson(inputParams); + if (sqlParams.empty() && stmt->bound_params) { + sqlParams = ConvertBoundParamsToJson(stmt->bound_params); } + eventCacheStack.Top().sqlParams = sqlParams; } zend_class_entry* helper_load_mysqli_link_class_entry() { From c222de1c797193ed31c6a9d4e9f8f7e9a1c5eca2 Mon Sep 17 00:00:00 2001 From: PopoviciMarian Date: Tue, 17 Feb 2026 14:20:46 +0200 Subject: [PATCH 9/9] fix: scope IDOR config to request context, single JSON callback, lazy load (#392) * fix: scope IDOR config to request context, single JSON callback, lazy load --- lib/API.h | 4 +- lib/php-extension/GoWrappers.cpp | 10 ++--- lib/php-extension/HandleIdorProtection.cpp | 21 ++++------ lib/php-extension/include/Cache.h | 6 +-- lib/request-processor/context/cache.go | 32 ++++++++++++++++ .../context/event_getters.go | 8 +--- .../context/request_context.go | 10 +++++ .../handle_enable_idor_protection.go | 28 -------------- lib/request-processor/handle_sql_queries.go | 2 +- lib/request-processor/main.go | 1 - .../vulnerabilities/idor/idor.go | 38 +++++-------------- 11 files changed, 68 insertions(+), 92 deletions(-) delete mode 100644 lib/request-processor/handle_enable_idor_protection.go diff --git a/lib/API.h b/lib/API.h index ee2df7ca6..379d66e28 100644 --- a/lib/API.h +++ b/lib/API.h @@ -16,7 +16,6 @@ enum EVENT_ID { EVENT_PRE_SHELL_EXECUTED, EVENT_PRE_PATH_ACCESSED, EVENT_PRE_SQL_QUERY_EXECUTED, - EVENT_ENABLE_IDOR_PROTECTION, MAX_EVENT_ID }; @@ -67,8 +66,7 @@ enum CALLBACK_ID { CONTEXT_TENANT_ID, CONTEXT_IDOR_DISABLED, - CONTEXT_IDOR_TENANT_COLUMN_NAME, - CONTEXT_IDOR_EXCLUDED_TABLES, + CONTEXT_IDOR_CONFIG, MAX_CALLBACK_ID }; diff --git a/lib/php-extension/GoWrappers.cpp b/lib/php-extension/GoWrappers.cpp index 7c9034356..7daa83d24 100644 --- a/lib/php-extension/GoWrappers.cpp +++ b/lib/php-extension/GoWrappers.cpp @@ -157,13 +157,9 @@ char* GoContextCallback(int callbackId) { ctx = "IDOR_DISABLED"; ret = requestCache.idorDisabled ? "1" : ""; break; - case CONTEXT_IDOR_TENANT_COLUMN_NAME: - ctx = "IDOR_TENANT_COLUMN_NAME"; - ret = GetEventCacheField(&EventCache::idorTenantColumnName); - break; - case CONTEXT_IDOR_EXCLUDED_TABLES: - ctx = "IDOR_EXCLUDED_TABLES"; - ret = GetEventCacheField(&EventCache::idorExcludedTables); + case CONTEXT_IDOR_CONFIG: + ctx = "IDOR_CONFIG"; + ret = requestCache.idorConfigJson; break; } } catch (std::exception& e) { diff --git a/lib/php-extension/HandleIdorProtection.cpp b/lib/php-extension/HandleIdorProtection.cpp index e5e1a6df7..92d29753e 100644 --- a/lib/php-extension/HandleIdorProtection.cpp +++ b/lib/php-extension/HandleIdorProtection.cpp @@ -2,8 +2,7 @@ ZEND_FUNCTION(enable_idor_protection) { ScopedTimer scopedTimer("enable_idor_protection", "aikido_op"); - ScopedEventContext scopedContext; - + if (IsAikidoDisabledOrBypassed()) { RETURN_BOOL(false); } @@ -23,8 +22,6 @@ ZEND_FUNCTION(enable_idor_protection) { RETURN_BOOL(false); } - eventCacheStack.Top().idorTenantColumnName = std::string(tenantColumnName, tenantColumnNameLength); - json excludedTablesJson = json::array(); if (excludedTablesZval) { HashTable *ht = Z_ARRVAL_P(excludedTablesZval); @@ -35,16 +32,12 @@ ZEND_FUNCTION(enable_idor_protection) { } } ZEND_HASH_FOREACH_END(); } - eventCacheStack.Top().idorExcludedTables = excludedTablesJson.dump(); - - try { - std::string outputEvent; - requestProcessor.SendEvent(EVENT_ENABLE_IDOR_PROTECTION, outputEvent); - action.Execute(outputEvent); - } catch (const std::exception &e) { - AIKIDO_LOG_ERROR("Exception encountered in enable_idor_protection: %s\n", e.what()); - RETURN_BOOL(false); - } + + json idorConfig = { + {"column_name", std::string(tenantColumnName, tenantColumnNameLength)}, + {"excluded_tables", excludedTablesJson} + }; + requestCache.idorConfigJson = idorConfig.dump(); AIKIDO_LOG_INFO("Enabled IDOR protection with tenant column '%s'\n", tenantColumnName); RETURN_BOOL(true); diff --git a/lib/php-extension/include/Cache.h b/lib/php-extension/include/Cache.h index 284a7561d..d408ea469 100644 --- a/lib/php-extension/include/Cache.h +++ b/lib/php-extension/include/Cache.h @@ -11,7 +11,8 @@ class RequestCache { std::string outgoingRequestRedirectUrl; std::string tenantId; bool idorDisabled = false; - + std::string idorConfigJson; + RequestCache() = default; void Reset(); }; @@ -39,9 +40,6 @@ class EventCache { std::string paramMatcherParam; std::string paramMatcherRegex; - std::string idorTenantColumnName; - std::string idorExcludedTables; - EventCache() = default; void Reset(); }; diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 5eb5e4d2f..0c267dd4c 100644 --- a/lib/request-processor/context/cache.go +++ b/lib/request-processor/context/cache.go @@ -3,6 +3,7 @@ package context // #include "../../API.h" import "C" import ( + "encoding/json" "main/globals" "main/helpers" "main/log" @@ -248,3 +249,34 @@ func ContextSetIsEndpointIpAllowed() { func ContextSetIsEndpointRateLimited() { Context.IsEndpointRateLimited = true } + +// ContextSetIdorConfig loads IDOR config lazily (on first GetIdorConfig() use) +// from PHP via CONTEXT_IDOR_CONFIG callback. +func ContextSetIdorConfig() { + if Context.IdorConfig != nil { + return + } + idorConfigJson := GetIdorConfigJson() + if idorConfigJson == "" { + return + } + + var payload struct { + ColumnName string `json:"column_name"` + ExcludedTables []string `json:"excluded_tables"` + } + if err := json.Unmarshal([]byte(idorConfigJson), &payload); err != nil { + log.Warnf("enable_idor_protection: failed to parse IDOR config: %s", err) + return + } + if payload.ColumnName == "" { + log.Warn("enable_idor_protection: tenant column name is empty!") + return + } + idorConfig := IdorConfig{ + TenantColumnName: payload.ColumnName, + ExcludedTables: payload.ExcludedTables, + } + ptr := &idorConfig + Context.IdorConfig = &ptr +} diff --git a/lib/request-processor/context/event_getters.go b/lib/request-processor/context/event_getters.go index 460df7065..1914ca3e1 100644 --- a/lib/request-processor/context/event_getters.go +++ b/lib/request-processor/context/event_getters.go @@ -69,12 +69,8 @@ func IsIdorDisabled() bool { return Context.Callback(C.CONTEXT_IDOR_DISABLED) == "1" } -func GetIdorTenantColumnName() string { - return Context.Callback(C.CONTEXT_IDOR_TENANT_COLUMN_NAME) -} - -func GetIdorExcludedTables() string { - return Context.Callback(C.CONTEXT_IDOR_EXCLUDED_TABLES) +func GetIdorConfigJson() string { + return Context.Callback(C.CONTEXT_IDOR_CONFIG) } func getHostNameAndPort(urlCallbackId int, portCallbackId int) (string, uint32) { // urlcallbackid is the type of data we request, eg C.OUTGOING_REQUEST_URL diff --git a/lib/request-processor/context/request_context.go b/lib/request-processor/context/request_context.go index 893783b32..5e2c9efaf 100644 --- a/lib/request-processor/context/request_context.go +++ b/lib/request-processor/context/request_context.go @@ -9,6 +9,11 @@ import ( type CallbackFunction func(int) string +type IdorConfig struct { + TenantColumnName string + ExcludedTables []string +} + /* Request level context cache (changes on each PHP request) */ type RequestContextData struct { Callback CallbackFunction // callback to access data from the PHP layer (C++ extension) about the current request and current event @@ -27,6 +32,7 @@ type RequestContextData struct { IsEndpointProtectionTurnedOff *bool IsEndpointIpAllowed *bool IsEndpointRateLimited bool + IdorConfig **IdorConfig UserAgent *string UserId *string UserName *string @@ -184,3 +190,7 @@ func IsEndpointProtectionTurnedOff() bool { func IsEndpointRateLimited() bool { return Context.IsEndpointRateLimited } + +func GetIdorConfig() *IdorConfig { + return GetFromCache(ContextSetIdorConfig, &Context.IdorConfig) +} diff --git a/lib/request-processor/handle_enable_idor_protection.go b/lib/request-processor/handle_enable_idor_protection.go deleted file mode 100644 index e1a1b6959..000000000 --- a/lib/request-processor/handle_enable_idor_protection.go +++ /dev/null @@ -1,28 +0,0 @@ -package main - -import ( - "encoding/json" - "main/context" - "main/log" - idor "main/vulnerabilities/idor" -) - -func OnEnableIdorProtection() string { - tenantColumnName := context.GetIdorTenantColumnName() - if tenantColumnName == "" { - log.Warn("enable_idor_protection: tenant column name is empty!") - return "" - } - - excludedTablesJson := context.GetIdorExcludedTables() - var excludedTables []string - if excludedTablesJson != "" { - if err := json.Unmarshal([]byte(excludedTablesJson), &excludedTables); err != nil { - log.Warnf("enable_idor_protection: failed to parse excluded tables: %s", err) - excludedTables = []string{} - } - } - - idor.SetIdorConfig(tenantColumnName, excludedTables) - return "" -} diff --git a/lib/request-processor/handle_sql_queries.go b/lib/request-processor/handle_sql_queries.go index 98dc27dcf..ab0519c19 100644 --- a/lib/request-processor/handle_sql_queries.go +++ b/lib/request-processor/handle_sql_queries.go @@ -26,7 +26,7 @@ func OnPreSqlQueryExecuted() string { return attack.ReportAttackDetected(res) } - if idor.IsIdorEnabled() && !context.IsIdorDisabled() { + if context.GetIdorConfig() != nil && !context.IsIdorDisabled() { tenantId := context.GetTenantId() sqlParams := context.GetSqlParams() idorResult := idor.CheckForIdorViolation(query, dialect, tenantId, sqlParams) diff --git a/lib/request-processor/main.go b/lib/request-processor/main.go index 15a0c6694..4b820ca5d 100644 --- a/lib/request-processor/main.go +++ b/lib/request-processor/main.go @@ -30,7 +30,6 @@ var eventHandlers = map[int]HandlerFunction{ C.EVENT_PRE_SHELL_EXECUTED: OnPreShellExecuted, C.EVENT_PRE_PATH_ACCESSED: OnPrePathAccessed, C.EVENT_PRE_SQL_QUERY_EXECUTED: OnPreSqlQueryExecuted, - C.EVENT_ENABLE_IDOR_PROTECTION: OnEnableIdorProtection, } func initializeServer(server *ServerData) { diff --git a/lib/request-processor/vulnerabilities/idor/idor.go b/lib/request-processor/vulnerabilities/idor/idor.go index ea8842d7d..b10ebb87c 100644 --- a/lib/request-processor/vulnerabilities/idor/idor.go +++ b/lib/request-processor/vulnerabilities/idor/idor.go @@ -4,30 +4,12 @@ import ( "encoding/json" "fmt" "main/attack" + "main/context" "main/log" "main/utils" zen_internals "main/vulnerabilities/zen-internals" ) -type IdorConfig struct { - TenantColumnName string - ExcludedTables []string -} - -var idorConfig *IdorConfig - -func SetIdorConfig(tenantColumnName string, excludedTables []string) { - idorConfig = &IdorConfig{ - TenantColumnName: tenantColumnName, - ExcludedTables: excludedTables, - } - log.Infof("IDOR protection enabled with tenant column '%s' and %d excluded tables", tenantColumnName, len(excludedTables)) -} - -func IsIdorEnabled() bool { - return idorConfig != nil -} - type TableRef struct { Name string `json:"name"` Alias *string `json:"alias,omitempty"` @@ -58,7 +40,7 @@ type AnalysisError struct { } func CheckForIdorViolation(query string, dialect string, tenantId string, sqlParams string) string { - if idorConfig == nil { + if context.GetIdorConfig() == nil { return "" } @@ -114,7 +96,7 @@ func checkWhereFilters(queryResult SqlQueryResult, tenantId string, params *SqlP if tenantFilter == nil { return fmt.Sprintf( "Zen IDOR protection: query on table '%s' is missing a filter on column '%s'", - table.Name, idorConfig.TenantColumnName, + table.Name, context.GetIdorConfig().TenantColumnName, ) } @@ -122,7 +104,7 @@ func checkWhereFilters(queryResult SqlQueryResult, tenantId string, params *SqlP if resolvedValue != "" && resolvedValue != tenantId { return fmt.Sprintf( "Zen IDOR protection: query on table '%s' filters '%s' with value '%s' but tenant ID is '%s'", - table.Name, idorConfig.TenantColumnName, resolvedValue, tenantId, + table.Name, context.GetIdorConfig().TenantColumnName, resolvedValue, tenantId, ) } } @@ -140,7 +122,7 @@ func checkInsert(queryResult SqlQueryResult, tenantId string, params *SqlParams) // INSERT ... SELECT without explicit columns return fmt.Sprintf( "Zen IDOR protection: INSERT on table '%s' is missing column '%s'", - table.Name, idorConfig.TenantColumnName, + table.Name, context.GetIdorConfig().TenantColumnName, ) } @@ -149,7 +131,7 @@ func checkInsert(queryResult SqlQueryResult, tenantId string, params *SqlParams) if tenantCol == nil { return fmt.Sprintf( "Zen IDOR protection: INSERT on table '%s' is missing column '%s'", - table.Name, idorConfig.TenantColumnName, + table.Name, context.GetIdorConfig().TenantColumnName, ) } @@ -157,7 +139,7 @@ func checkInsert(queryResult SqlQueryResult, tenantId string, params *SqlParams) if resolvedValue != "" && resolvedValue != tenantId { return fmt.Sprintf( "Zen IDOR protection: INSERT on table '%s' sets '%s' to '%s' but tenant ID is '%s'", - table.Name, idorConfig.TenantColumnName, resolvedValue, tenantId, + table.Name, context.GetIdorConfig().TenantColumnName, resolvedValue, tenantId, ) } } @@ -168,7 +150,7 @@ func checkInsert(queryResult SqlQueryResult, tenantId string, params *SqlParams) func findTenantFilter(queryResult SqlQueryResult, table TableRef) *FilterColumn { for i, f := range queryResult.Filters { - if f.Column != idorConfig.TenantColumnName { + if f.Column != context.GetIdorConfig().TenantColumnName { continue } @@ -190,7 +172,7 @@ func findTenantFilter(queryResult SqlQueryResult, table TableRef) *FilterColumn func findTenantColumn(row []InsertColumn) *InsertColumn { for i, c := range row { - if c.Column == idorConfig.TenantColumnName { + if c.Column == context.GetIdorConfig().TenantColumnName { return &row[i] } } @@ -198,7 +180,7 @@ func findTenantColumn(row []InsertColumn) *InsertColumn { } func isExcludedTable(tableName string) bool { - for _, excluded := range idorConfig.ExcludedTables { + for _, excluded := range context.GetIdorConfig().ExcludedTables { if excluded == tableName { return true }