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()