Skip to content

Commit 0d71df4

Browse files
committed
Add IDOR protection
1 parent 171c337 commit 0d71df4

25 files changed

+1645
-72
lines changed

docs/idor-protection.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# IDOR Protection
2+
3+
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.
4+
5+
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 raises an error if a query is missing that filter or uses the wrong tenant ID, catching mistakes like:
6+
7+
- A `SELECT` that forgets the tenant filter, letting one account read another's orders
8+
- An `UPDATE` or `DELETE` without a tenant filter, letting one account modify another's data
9+
- An `INSERT` that omits the tenant column, creating orphaned or misassigned rows
10+
11+
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.
12+
13+
> [!IMPORTANT]
14+
> IDOR protection always raises an `Aikido::Zen::IDOR::Error` on violations regardless of block/detect mode. A missing filter is a developer bug, not an external attack.
15+
16+
## Setup
17+
18+
### 1. Enable IDOR protection at startup
19+
20+
```ruby
21+
...
22+
23+
Aikido::Zen.config.idor_tenant_column_name = "tenant_id"
24+
Aikido::Zen.config.idor_excluded_table_names = ["users"]
25+
26+
...
27+
```
28+
29+
- `idor_tenant_column_name` — the column name that identifies the tenant in your database tables (e.g. `account_id`, `organization_id`, `team_id`).
30+
- `idor_excluded_table_names` — 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).
31+
32+
### 2. Set the tenant ID per request
33+
34+
Every request must have a tenant ID when IDOR protection is enabled. Call `Aikido::Zen.set_tenant_id` early in your request handler (e.g. in middleware after authentication):
35+
36+
```ruby
37+
Aikido::Zen.set_tenant_id(1)
38+
```
39+
40+
> [!IMPORTANT]
41+
> If `Aikido::Zen.set_tenant_id` is not called for a request, Zen will raise an `Aikido::Zen::IDOR::Error` when a SQL query is executed.
42+
43+
### 3. Bypass for specific queries (optional)
44+
45+
Some queries don't need tenant filtering (e.g. aggregations across all tenants for an admin dashboard). Use `Aikido::Zen.without_idor_protection` to bypass the check for a specific block:
46+
47+
```ruby
48+
...
49+
50+
# IDOR checks are skipped for queries inside this block
51+
result = Aikido::Zen.without_idor_protection do
52+
db.query("SELECT count(*) FROM agents WHERE status = 'running'");
53+
end
54+
55+
...
56+
```
57+
58+
## Troubleshooting
59+
60+
<details>
61+
<summary>Missing tenant filter</summary>
62+
63+
```
64+
Zen IDOR protection: query on table 'orders' is missing a filter on column 'tenant_id'
65+
```
66+
67+
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.
68+
69+
</details>
70+
71+
<details>
72+
<summary>Wrong tenant ID value</summary>
73+
74+
```
75+
Zen IDOR protection: query on table 'orders' filters 'tenant_id' with value '456' but tenant ID is '123'
76+
```
77+
78+
This means the query filters on `tenant_id`, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
79+
80+
</details>
81+
82+
<details>
83+
<summary>Missing tenant column in INSERT</summary>
84+
85+
```
86+
Zen IDOR protection: INSERT on table 'orders' is missing column 'tenant_id'
87+
```
88+
89+
This means an `INSERT` statement doesn't include the tenant column. Every INSERT must include the tenant column with the correct tenant ID value.
90+
91+
</details>
92+
93+
<details>
94+
<summary>Wrong tenant ID in INSERT</summary>
95+
96+
```
97+
Zen IDOR protection: INSERT on table 'orders' sets 'tenant_id' to '456' but tenant ID is '123'
98+
```
99+
100+
This means the INSERT includes the tenant column, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
101+
102+
</details>
103+
104+
<details>
105+
<summary>Missing Aikido::Zen.set_tenant_id call</summary>
106+
107+
```
108+
Zen IDOR protection: Aikido::Zen.set_tenant_id was not called for this request. Every request must have a tenant ID when IDOR protection is enabled.
109+
```
110+
111+
</details>
112+
113+
## Supported databases
114+
115+
- SQLite (via `sqlite3` Gem)
116+
- PostgreSQL (via `pg` Gem)
117+
- MySQL (via `mysql2` and `trilogy` Gems)
118+
119+
Any ORM or query builder that uses these database packages under the hood is supported (e.g. ActiveRecord). ORMs that use their own database engine are not supported unless configured to use a supported driver adapter.
120+
121+
## Limitations
122+
123+
## Statements that are always allowed
124+
125+
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:
126+
127+
- DDL — `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, ...
128+
- Session commands — `SET`, `SHOW`, ...
129+
- Transactions — `BEGIN`, `COMMIT`, `ROLLBACK`, ...

lib/aikido/zen.rb

Lines changed: 77 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
require_relative "zen/runtime_settings"
2727
require_relative "zen/rate_limiter"
2828
require_relative "zen/attack_wave"
29+
require_relative "zen/sql"
30+
require_relative "zen/idor"
2931
require_relative "zen/scanners"
3032

3133
module Aikido
@@ -213,17 +215,87 @@ class << self
213215
alias_method :set_user, :track_user
214216
end
215217

218+
# @return [Aikido::Zen::AttackWave::Detector] the attack wave detector.
219+
def self.attack_wave_detector
220+
@attack_wave_detector ||= AttackWave::Detector.new
221+
end
222+
223+
# @param id [Integer, String, nil]
224+
# @return [void]
225+
def self.set_tenant_id(id)
226+
context = current_context
227+
return unless context
228+
229+
context.request.tenant_id = id
230+
end
231+
232+
# @return [void]
233+
def self.enable_idor_protection
234+
context = current_context
235+
return unless context
236+
237+
context.idor_protection_enabled = true
238+
end
239+
240+
# @return [void]
241+
def self.disable_idor_protection
242+
context = current_context
243+
return unless context
244+
245+
context.idor_protection_enabled = false
246+
end
247+
248+
# @return [Aikido::Zen::IDOR::Protector]
249+
def self.idor_protector
250+
@idor_protector ||= IDOR::Protector.new
251+
end
252+
253+
# @param sql [String]
254+
# @param dialet [Symbol]
255+
# @return [void]
256+
# @raise [Aikido::Zen::IDOR::Error]
257+
def self.idor_protect(sql, dialect_name, params = [])
258+
context = current_context
259+
return unless context
260+
261+
idor_protector.protect(sql, dialect_name, params, context)
262+
end
263+
264+
# Execute a block with the IDOR protection state set.
265+
#
266+
# @yield the block to execute with the IDOR protection state set.
267+
# @return [Object] the result of the block
268+
# @raise [ArgumentError] if no block is given
269+
def self.idor_protection(enabled)
270+
raise ArgumentError, "block required" unless block_given?
271+
272+
if current_context
273+
begin
274+
idor_protection_enabled = current_context.idor_protection_enabled
275+
current_context.idor_protection_enabled = enabled
276+
yield
277+
ensure
278+
current_context.idor_protection_enabled = idor_protection_enabled
279+
end
280+
else
281+
yield
282+
end
283+
end
284+
285+
def self.with_idor_protection(&blk)
286+
idor_protection(true, &blk)
287+
end
288+
289+
def self.without_idor_protection(&blk)
290+
idor_protection(false, &blk)
291+
end
292+
216293
# Marks that the Zen middleware was installed properly
217294
# @return void
218295
def self.middleware_installed!
219296
collector.middleware_installed!
220297
end
221298

222-
# @return [Aikido::Zen::AttackWave::Detector] the attack wave detector.
223-
def self.attack_wave_detector
224-
@attack_wave_detector ||= AttackWave::Detector.new
225-
end
226-
227299
# @!visibility private
228300
# Load all sources.
229301
#

lib/aikido/zen/config.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,18 @@ class Config
188188
# Defaults to 15 entries.
189189
attr_accessor :attack_wave_max_cache_samples
190190

191+
# @return [String] the tenant column name for IDOR protection.
192+
# Defaults to nil.
193+
attr_accessor :idor_tenant_column_name
194+
195+
# @return [Array<String>] the table names to exclude for IDOR protection.
196+
# Defaults to [].
197+
attr_accessor :idor_excluded_table_names
198+
199+
# @return [Integer] the maximum number of entries in the LRU cache.
200+
# Defaults to 1000 entries.
201+
attr_accessor :idor_max_cache_entries
202+
191203
def initialize
192204
self.insert_middleware_after = ::ActionDispatch::RemoteIp
193205
self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
@@ -227,6 +239,9 @@ def initialize
227239
self.attack_wave_min_time_between_events = 20 * 60 * 1000 # 20 min (ms)
228240
self.attack_wave_max_cache_entries = 10_000
229241
self.attack_wave_max_cache_samples = 15
242+
self.idor_tenant_column_name = nil
243+
self.idor_excluded_table_names = []
244+
self.idor_max_cache_entries = 1000
230245
end
231246

232247
# Set the base URL for API requests.

lib/aikido/zen/context.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ def self.from_rack_env(env, config = Aikido::Zen.config)
3030
attr_accessor :protection_disabled
3131
alias_method :protection_disabled?, :protection_disabled
3232

33+
# @return [Boolean]
34+
attr_accessor :idor_protection_enabled
35+
3336
# @param request [Rack::Request] a Request object that implements the
3437
# Rack::Request API, to which we will delegate behavior.
3538
# @param settings [Aikido::Zen::RuntimeSettings]
@@ -45,6 +48,7 @@ def initialize(request, settings: Aikido::Zen.runtime_settings, &sources)
4548
@metadata = {}
4649
@scanning = false
4750
@protection_disabled = false
51+
@idor_protection_enabled = false
4852
end
4953

5054
# Fetch some metadata stored in the Context.

lib/aikido/zen/idor.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "idor/analysis_result"
4+
require_relative "idor/protector"

0 commit comments

Comments
 (0)