diff --git a/docs/idor-protection.md b/docs/idor-protection.md new file mode 100644 index 00000000..9bb71f42 --- /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 3b0a2096..0881ae70 100644 --- a/lib/API.h +++ b/lib/API.h @@ -61,9 +61,14 @@ enum CALLBACK_ID { SQL_QUERY, SQL_DIALECT, + SQL_PARAMS, MODULE, + CONTEXT_TENANT_ID, + CONTEXT_IDOR_DISABLED, + CONTEXT_IDOR_CONFIG, + MAX_CALLBACK_ID }; diff --git a/lib/php-extension/Action.cpp b/lib/php-extension/Action.cpp index 7538b75b..2129f5db 100644 --- a/lib/php-extension/Action.cpp +++ b/lib/php-extension/Action.cpp @@ -94,6 +94,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; whitelisted = false; diff --git a/lib/php-extension/Aikido.cpp b/lib/php-extension/Aikido.cpp index 1e08352a..b8de045e 100644 --- a/lib/php-extension/Aikido.cpp +++ b/lib/php-extension/Aikido.cpp @@ -154,6 +154,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_NS_FE("aikido", worker_rinit, arginfo_aikido_worker_rinit) ZEND_NS_FE("aikido", worker_rshutdown, arginfo_aikido_worker_rshutdown) ZEND_FE_END diff --git a/lib/php-extension/GoWrappers.cpp b/lib/php-extension/GoWrappers.cpp index fbd246d0..e151898e 100644 --- a/lib/php-extension/GoWrappers.cpp +++ b/lib/php-extension/GoWrappers.cpp @@ -135,6 +135,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); @@ -151,6 +155,18 @@ 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_CONFIG: + ctx = "IDOR_CONFIG"; + ret = requestCache.idorConfigJson; + 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 62acc7ff..c9599661 100644 --- a/lib/php-extension/Handle.cpp +++ b/lib/php-extension/Handle.cpp @@ -13,6 +13,15 @@ ACTION_STATUS aikido_process_event(EVENT_ID& eventId, std::string& sink) { std::string outputEvent; requestProcessorInstance.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)) { statsMap[sink].IncrementAttacksDetected(); } diff --git a/lib/php-extension/HandleIdorProtection.cpp b/lib/php-extension/HandleIdorProtection.cpp new file mode 100644 index 00000000..2de010d1 --- /dev/null +++ b/lib/php-extension/HandleIdorProtection.cpp @@ -0,0 +1,96 @@ +#include "Includes.h" + +ZEND_FUNCTION(enable_idor_protection) { + ScopedTimer scopedTimer("enable_idor_protection", "aikido_op"); + auto& requestCache = AIKIDO_GLOBAL(requestCache); + + 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); + } + + 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(); + } + + 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); +} + +ZEND_FUNCTION(set_tenant_id) { + auto& requestCache = AIKIDO_GLOBAL(requestCache); + + 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) { + auto& requestCache = AIKIDO_GLOBAL(requestCache); + 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_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 f1be5ac7..002db8d4 100644 --- a/lib/php-extension/HandleQueries.cpp +++ b/lib/php-extension/HandleQueries.cpp @@ -54,6 +54,134 @@ 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(-1, ' ', false, json::error_handler_t::replace); +} + +/* 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(); + } + + 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); + + 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(-1, ' ', false, json::error_handler_t::replace); +} + AIKIDO_HANDLER_FUNCTION(handle_pre_pdostatement_execute) { scopedTimer.SetSink(sink, "sql_op"); @@ -63,24 +191,36 @@ 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; } eventId = EVENT_PRE_SQL_QUERY_EXECUTED; auto& eventCacheStack = AIKIDO_GLOBAL(eventCacheStack); - 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); #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() { diff --git a/lib/php-extension/config.m4 b/lib/php-extension/config.m4 index 7a769887..cca9a524 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 diff --git a/lib/php-extension/include/Action.h b/lib/php-extension/include/Action.h index 131776db..5318a434 100644 --- a/lib/php-extension/include/Action.h +++ b/lib/php-extension/include/Action.h @@ -38,6 +38,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 55e9c59c..ca527a61 100644 --- a/lib/php-extension/include/Cache.h +++ b/lib/php-extension/include/Cache.h @@ -9,7 +9,10 @@ class RequestCache { std::string rateLimitGroup; std::string outgoingRequestUrl; std::string outgoingRequestRedirectUrl; - + std::string tenantId; + bool idorDisabled = false; + std::string idorConfigJson; + RequestCache() = default; /* @@ -42,6 +45,7 @@ class EventCache { std::string sqlQuery; std::string sqlDialect; + std::string sqlParams; std::string paramMatcherParam; std::string paramMatcherRegex; diff --git a/lib/php-extension/include/HandleIdorProtection.h b/lib/php-extension/include/HandleIdorProtection.h new file mode 100644 index 00000000..06e75254 --- /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 eaeef525..72876424 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/php-extension/include/PhpWrappers.h b/lib/php-extension/include/PhpWrappers.h index 2d35c2da..7e665f7d 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 diff --git a/lib/request-processor/context/cache.go b/lib/request-processor/context/cache.go index 182ce96d..e09401a6 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/helpers" "main/instance" "main/log" @@ -306,3 +307,36 @@ func ContextSetIsEndpointRateLimited(instance *instance.RequestProcessorInstance c := GetContext(instance) c.IsEndpointRateLimited = true } + +func ContextSetIdorConfig(instance *instance.RequestProcessorInstance) { + c := GetContext(instance) + if c.IdorConfig != nil { + return + } + if c.Callback == nil { + return + } + idorConfigJson := GetIdorConfigJson(instance) + 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(instance, "enable_idor_protection: failed to parse IDOR config: %s", err) + return + } + if payload.ColumnName == "" { + log.Warn(instance, "enable_idor_protection: tenant column name is empty!") + return + } + idorConfig := IdorConfig{ + TenantColumnName: payload.ColumnName, + ExcludedTables: payload.ExcludedTables, + } + ptr := &idorConfig + c.IdorConfig = &ptr +} diff --git a/lib/request-processor/context/event_getters.go b/lib/request-processor/context/event_getters.go index 683f3875..df319257 100644 --- a/lib/request-processor/context/event_getters.go +++ b/lib/request-processor/context/event_getters.go @@ -44,6 +44,10 @@ func GetSqlDialect(instance *instance.RequestProcessorInstance) string { return GetContext(instance).Callback(instance, C.SQL_DIALECT) } +func GetSqlParams(instance *instance.RequestProcessorInstance) string { + return GetContext(instance).Callback(instance, C.SQL_PARAMS) +} + func GetModule(instance *instance.RequestProcessorInstance) string { return GetContext(instance).Callback(instance, C.MODULE) } @@ -59,6 +63,18 @@ func GetParamMatcher(instance *instance.RequestProcessorInstance) (string, strin return param, regex } +func GetTenantId(instance *instance.RequestProcessorInstance) string { + return GetContext(instance).Callback(instance, C.CONTEXT_TENANT_ID) +} + +func IsIdorDisabled(instance *instance.RequestProcessorInstance) bool { + return GetContext(instance).Callback(instance, C.CONTEXT_IDOR_DISABLED) == "1" +} + +func GetIdorConfigJson(instance *instance.RequestProcessorInstance) string { + return GetContext(instance).Callback(instance, C.CONTEXT_IDOR_CONFIG) +} + func getHostNameAndPort(instance *instance.RequestProcessorInstance, urlCallbackId int, portCallbackId int) (string, uint32) { ctx := GetContext(instance) urlStr := ctx.Callback(instance, urlCallbackId) diff --git a/lib/request-processor/context/request_context.go b/lib/request-processor/context/request_context.go index d498805e..7c9f59fb 100644 --- a/lib/request-processor/context/request_context.go +++ b/lib/request-processor/context/request_context.go @@ -12,6 +12,11 @@ import ( type CallbackFunction func(*instance.RequestProcessorInstance, int) string +type IdorConfig struct { + TenantColumnName string + ExcludedTables []string +} + /* Request level context cache (changes on each PHP request) */ type RequestContextData struct { instance *instance.RequestProcessorInstance // CACHED: Instance pointer for fast access @@ -31,6 +36,7 @@ type RequestContextData struct { IsEndpointProtectionTurnedOff *bool IsEndpointIpAllowed *int IsEndpointRateLimited bool + IdorConfig **IdorConfig UserAgent *string UserId *string UserName *string @@ -249,3 +255,8 @@ func GetEndpointIpAllowed(instance *instance.RequestProcessorInstance) int { ctx := GetContext(instance) return GetFromCache(instance, ContextSetIsEndpointIpAllowed, &ctx.IsEndpointIpAllowed) } + +func GetIdorConfig(instance *instance.RequestProcessorInstance) *IdorConfig { + ctx := GetContext(instance) + return GetFromCache(instance, ContextSetIdorConfig, &ctx.IdorConfig) +} diff --git a/lib/request-processor/handle_sql_queries.go b/lib/request-processor/handle_sql_queries.go index 5766e3e3..6a7c3aab 100644 --- a/lib/request-processor/handle_sql_queries.go +++ b/lib/request-processor/handle_sql_queries.go @@ -5,6 +5,7 @@ import ( "main/context" "main/instance" "main/log" + idor "main/vulnerabilities/idor" sql_injection "main/vulnerabilities/sql-injection" ) @@ -25,5 +26,15 @@ func OnPreSqlQueryExecuted(instance *instance.RequestProcessorInstance) string { if res != nil { return attack.ReportAttackDetected(res, instance) } + + if context.GetIdorConfig(instance) != nil && !context.IsIdorDisabled(instance) { + tenantId := context.GetTenantId(instance) + sqlParams := context.GetSqlParams(instance) + idorResult := idor.CheckForIdorViolation(instance, query, dialect, tenantId, sqlParams) + if idorResult != "" { + return idorResult + } + } + return "" } diff --git a/lib/request-processor/utils/utils.go b/lib/request-processor/utils/utils.go index 5dcddc51..2425817f 100644 --- a/lib/request-processor/utils/utils.go +++ b/lib/request-processor/utils/utils.go @@ -441,7 +441,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 00000000..0e3645ba --- /dev/null +++ b/lib/request-processor/vulnerabilities/idor/idor.go @@ -0,0 +1,259 @@ +package idor + +import ( + "encoding/json" + "fmt" + "main/attack" + "main/context" + "main/instance" + "main/log" + "main/utils" + zen_internals "main/vulnerabilities/zen-internals" +) + +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(instance *instance.RequestProcessorInstance, query string, dialect string, tenantId string, sqlParams string) string { + if context.GetIdorConfig(instance) == 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(instance, "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(instance, queryResult, tenantId, params); msg != "" { + return buildIdorViolationAction(msg) + } + case "select", "update", "delete": + if msg := checkWhereFilters(instance, queryResult, tenantId, params); msg != "" { + return buildIdorViolationAction(msg) + } + } + } + + return "" +} + +func checkWhereFilters(instance *instance.RequestProcessorInstance, queryResult SqlQueryResult, tenantId string, params *SqlParams) string { + idorConfig := context.GetIdorConfig(instance) + for _, table := range queryResult.Tables { + if isExcludedTable(instance, table.Name) { + continue + } + + tenantFilter := findTenantFilter(instance, 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(instance *instance.RequestProcessorInstance, queryResult SqlQueryResult, tenantId string, params *SqlParams) string { + idorConfig := context.GetIdorConfig(instance) + for _, table := range queryResult.Tables { + if isExcludedTable(instance, table.Name) { + continue + } + + if queryResult.InsertColumns == nil { + 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(instance, 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(instance *instance.RequestProcessorInstance, queryResult SqlQueryResult, table TableRef) *FilterColumn { + idorConfig := context.GetIdorConfig(instance) + for i, f := range queryResult.Filters { + if f.Column != idorConfig.TenantColumnName { + continue + } + + if f.Table != nil { + if *f.Table == table.Name || (table.Alias != nil && *f.Table == *table.Alias) { + return &queryResult.Filters[i] + } + } else { + if len(queryResult.Tables) == 1 { + return &queryResult.Filters[i] + } + } + } + + return nil +} + +func findTenantColumn(instance *instance.RequestProcessorInstance, row []InsertColumn) *InsertColumn { + idorConfig := context.GetIdorConfig(instance) + for i, c := range row { + if c.Column == idorConfig.TenantColumnName { + return &row[i] + } + } + return nil +} + +func isExcludedTable(instance *instance.RequestProcessorInstance, tableName string) bool { + idorConfig := context.GetIdorConfig(instance) + 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 4676ad3f..61055110 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 ( type ZenInternalsLibrary struct { handle unsafe.Pointer detectSqlInjection C.detect_sql_injection_func + idorAnalyzeSql C.idor_analyze_sql_func + freeString C.free_string_func } var zenLib = &ZenInternalsLibrary{} @@ -57,12 +71,37 @@ func Init() bool { zenLib.handle = handle zenLib.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(nil, "Failed to load idor_analyze_sql_ffi function from zen-internals library!") + return false + } + + zenLib.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(nil, "Failed to load free_string function from zen-internals library!") + return false + } + + zenLib.freeString = (C.free_string_func)(vFreeString) + log.Debugf(nil, "Loaded zen-internals library!") return true } func Uninit() { zenLib.detectSqlInjection = nil + zenLib.idorAnalyzeSql = nil + zenLib.freeString = nil if zenLib.handle != nil { C.dlclose(zenLib.handle) @@ -95,3 +134,23 @@ func DetectSQLInjection(query string, user_input string, dialect int) int { log.Debugf(nil, "DetectSqlInjection(\"%s\", \"%s\", %d) -> %d", query, user_input, dialect, result) return result } + +func IdorAnalyzeSql(query string, dialect int) string { + if zenLib.idorAnalyzeSql == nil || zenLib.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(zenLib.idorAnalyzeSql, cQuery, queryLen, C.int(dialect)) + if resultPtr == nil { + return "" + } + + result := C.GoString(resultPtr) + C.call_free_string(zenLib.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 00000000..ce7181fd --- /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 00000000..ae9ce76d --- /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 00000000..a1ae3327 --- /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 00000000..430d3112 --- /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 00000000..f0f31b9f --- /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()