aikido-zen 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bfcc07a5e3256e9d9d35f89bea1cf884d2c51bf23d497a40401f61d7335a3dbc
4
- data.tar.gz: 81055a97f6a640bafb3bfd29fbc96f0c0eeb165046191bd68518109908d92817
3
+ metadata.gz: aa5de81052f55031065a0e154a9fe929d8928832cbaff2cad5657f0fd262a8ed
4
+ data.tar.gz: 0f7b951bef59f101be88141f284fda6f9786803868e3e54be84fb1f7c5a7909b
5
5
  SHA512:
6
- metadata.gz: dba14387480fa74530c2120e123aa62c9e9b81054fa58149f26a5e4d6aeca4da5fc96732add6313ba02d3fecc5c6c8a587a208e47916e2a136a277159a86b429
7
- data.tar.gz: 523e4fe6c7b14586a5d9e893ad51407fe6c46c52c408eea730ad8f45bab544d5861198a8e8fa5f6e6e19c69bc532bacdadcf1da4e8c74c6e89549c3f3108e68e
6
+ metadata.gz: 36338bd37e39be16311ead1f8caae49dc378f62b8641ee28fb0d7f264f6c3578f1a3db1291e33613e5c4ca443d5b3ba5e64babe827a6a5dcd9cc55fc1e787184
7
+ data.tar.gz: 31cce0645b2d3d092424c41bfccdbcc6166efb9295101074727cdc13b48a1914b2ad3c3db74010996ff2072d1b04231740ea3f90cb4b8b372201af5a4d2ed6fc
@@ -0,0 +1,130 @@
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_protection_enabled = true
24
+ Aikido::Zen.config.idor_tenant_column_name = "tenant_id"
25
+ Aikido::Zen.config.idor_excluded_table_names = ["users"]
26
+
27
+ ...
28
+ ```
29
+
30
+ - `idor_tenant_column_name` — the column name that identifies the tenant in your database tables (e.g. `account_id`, `organization_id`, `team_id`).
31
+ - `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).
32
+
33
+ ### 2. Set the tenant ID per request
34
+
35
+ 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):
36
+
37
+ ```ruby
38
+ Aikido::Zen.set_tenant_id(1)
39
+ ```
40
+
41
+ > [!IMPORTANT]
42
+ > 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.
43
+
44
+ ### 3. Bypass for specific queries (optional)
45
+
46
+ 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:
47
+
48
+ ```ruby
49
+ ...
50
+
51
+ # IDOR checks are skipped for queries inside this block
52
+ result = Aikido::Zen.without_idor_protection do
53
+ db.execute("SELECT count(*) FROM agents WHERE status = 'running'");
54
+ end
55
+
56
+ ...
57
+ ```
58
+
59
+ ## Troubleshooting
60
+
61
+ <details>
62
+ <summary>Missing tenant filter</summary>
63
+
64
+ ```
65
+ Zen IDOR protection: query on table 'orders' is missing a filter on column 'tenant_id'
66
+ ```
67
+
68
+ 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.
69
+
70
+ </details>
71
+
72
+ <details>
73
+ <summary>Wrong tenant ID value</summary>
74
+
75
+ ```
76
+ Zen IDOR protection: query on table 'orders' filters 'tenant_id' with value '456' but tenant ID is '123'
77
+ ```
78
+
79
+ This means the query filters on `tenant_id`, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
80
+
81
+ </details>
82
+
83
+ <details>
84
+ <summary>Missing tenant column in INSERT</summary>
85
+
86
+ ```
87
+ Zen IDOR protection: INSERT on table 'orders' is missing column 'tenant_id'
88
+ ```
89
+
90
+ This means an `INSERT` statement doesn't include the tenant column. Every INSERT must include the tenant column with the correct tenant ID value.
91
+
92
+ </details>
93
+
94
+ <details>
95
+ <summary>Wrong tenant ID in INSERT</summary>
96
+
97
+ ```
98
+ Zen IDOR protection: INSERT on table 'orders' sets 'tenant_id' to '456' but tenant ID is '123'
99
+ ```
100
+
101
+ This means the INSERT includes the tenant column, but the value doesn't match the tenant ID set via `Aikido::Zen.set_tenant_id`.
102
+
103
+ </details>
104
+
105
+ <details>
106
+ <summary>Missing Aikido::Zen.set_tenant_id call</summary>
107
+
108
+ ```
109
+ 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.
110
+ ```
111
+
112
+ </details>
113
+
114
+ ## Supported databases
115
+
116
+ - SQLite (via `sqlite3` Gem)
117
+ - PostgreSQL (via `pg` Gem)
118
+ - MySQL (via `mysql2` and `trilogy` Gems)
119
+
120
+ 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.
121
+
122
+ ## Limitations
123
+
124
+ ## Statements that are always allowed
125
+
126
+ 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:
127
+
128
+ - DDL — `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, ...
129
+ - Session commands — `SET`, `SHOW`, ...
130
+ - Transactions — `BEGIN`, `COMMIT`, `ROLLBACK`, ...
@@ -199,6 +199,23 @@ module Aikido::Zen
199
199
  # Defaults to 1.0 seconds.
200
200
  attr_accessor :redos_regexp_timeout
201
201
 
202
+ # @return [Boolean] whether the IDOR protection feature is enabled.
203
+ # Defaults to false.
204
+ attr_accessor :idor_protection_enabled
205
+ alias_method :idor_protection_enabled?, :idor_protection_enabled
206
+
207
+ # @return [String] the tenant column name for IDOR protection.
208
+ # Defaults to nil.
209
+ attr_accessor :idor_tenant_column_name
210
+
211
+ # @return [Array<String>] the table names to exclude for IDOR protection.
212
+ # Defaults to [].
213
+ attr_accessor :idor_excluded_table_names
214
+
215
+ # @return [Integer] the maximum number of entries in the LRU cache.
216
+ # Defaults to 1000 entries.
217
+ attr_accessor :idor_max_cache_entries
218
+
202
219
  def initialize
203
220
  self.insert_middleware_after = ::ActionDispatch::RemoteIp
204
221
  self.disabled = read_boolean_from_env(ENV.fetch("AIKIDO_DISABLE", false)) || read_boolean_from_env(ENV.fetch("AIKIDO_DISABLED", false))
@@ -240,6 +257,10 @@ module Aikido::Zen
240
257
  self.attack_wave_max_cache_entries = 10_000
241
258
  self.attack_wave_max_cache_samples = 15
242
259
  self.redos_regexp_timeout = 1.0
260
+ self.idor_protection_enabled = false
261
+ self.idor_tenant_column_name = nil
262
+ self.idor_excluded_table_names = []
263
+ self.idor_max_cache_entries = 1000
243
264
  end
244
265
 
245
266
  # Set the base URL for API requests.
@@ -30,6 +30,10 @@ module Aikido::Zen
30
30
  attr_accessor :protection_disabled
31
31
  alias_method :protection_disabled?, :protection_disabled
32
32
 
33
+ # @return [Boolean]
34
+ attr_accessor :idor_protection_enabled
35
+ alias_method :idor_protection_enabled?, :idor_protection_enabled
36
+
33
37
  # @param request [Rack::Request] a Request object that implements the
34
38
  # Rack::Request API, to which we will delegate behavior.
35
39
  # @param settings [Aikido::Zen::RuntimeSettings]
@@ -45,6 +49,7 @@ module Aikido::Zen
45
49
  @metadata = {}
46
50
  @scanning = false
47
51
  @protection_disabled = false
52
+ @idor_protection_enabled = false
48
53
  end
49
54
 
50
55
  # Fetch some metadata stored in the Context.
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module IDOR
5
+ class Table
6
+ def self.from_json(data)
7
+ new(
8
+ name: data["name"],
9
+ alt_name: data["alias"]
10
+ )
11
+ end
12
+
13
+ # @param name [String]
14
+ # @param alt_name [String, nil]
15
+ def initialize(name:, alt_name: nil)
16
+ @name = name
17
+ @alt_name = alt_name
18
+ end
19
+
20
+ # @return [String]
21
+ attr_accessor :name
22
+ # @return [String, nil]
23
+ attr_accessor :alt_name
24
+
25
+ def ==(other)
26
+ other.is_a?(self.class) &&
27
+ other.name == name &&
28
+ other.alt_name == alt_name
29
+ end
30
+ alias_method :eql?, :==
31
+ end
32
+
33
+ class FilterColumn
34
+ def self.from_json(data)
35
+ new(
36
+ table_qualifier: data["table"],
37
+ name: data["column"],
38
+ value: data["value"],
39
+ is_placeholder: data["is_placeholder"],
40
+ placeholder_number: data["placeholder_number"]
41
+ )
42
+ end
43
+
44
+ # @param table_qualifier [String, nil]
45
+ # @param name [String]
46
+ # @param value [String]
47
+ # @param is_placeholder [Boolean]
48
+ # @param placeholder_number [Integer, nil]
49
+ def initialize(table_qualifier:, name:, value:, is_placeholder:, placeholder_number: nil)
50
+ @table_qualifier = table_qualifier
51
+ @name = name
52
+ @value = value
53
+ @is_placeholder = is_placeholder
54
+ @placeholder_number = placeholder_number
55
+ end
56
+
57
+ # @return [String, nil]
58
+ attr_accessor :table_qualifier
59
+
60
+ # @return [String]
61
+ attr_accessor :name
62
+
63
+ # @return [String]
64
+ attr_accessor :value
65
+
66
+ # @return [Boolean]
67
+ attr_accessor :is_placeholder
68
+
69
+ # @return [Integer, nil]
70
+ attr_accessor :placeholder_number
71
+
72
+ def ==(other)
73
+ other.is_a?(self.class) &&
74
+ other.table_qualifier == table_qualifier &&
75
+ other.name == name &&
76
+ other.value == value &&
77
+ other.is_placeholder == is_placeholder &&
78
+ other.placeholder_number == placeholder_number
79
+ end
80
+ alias_method :eql?, :==
81
+ end
82
+
83
+ class InsertColumn
84
+ def self.from_json(data)
85
+ new(
86
+ name: data["column"],
87
+ value: data["value"],
88
+ is_placeholder: data["is_placeholder"],
89
+ placeholder_number: data["placeholder_number"]
90
+ )
91
+ end
92
+
93
+ # @param name [String]
94
+ # @param value [String]
95
+ # @param is_placeholder [Boolean]
96
+ # @param placeholder_number [Integer, nil]
97
+ def initialize(name:, value:, is_placeholder:, placeholder_number: nil)
98
+ @name = name
99
+ @value = value
100
+ @is_placeholder = is_placeholder
101
+ @placeholder_number = placeholder_number
102
+ end
103
+
104
+ # @return [String]
105
+ attr_accessor :name
106
+
107
+ # @return [String]
108
+ attr_accessor :value
109
+
110
+ # @return [Boolean]
111
+ attr_accessor :is_placeholder
112
+
113
+ # @return [Integer, nil]
114
+ attr_accessor :placeholder_number
115
+
116
+ def ==(other)
117
+ other.is_a?(self.class) &&
118
+ other.name == name &&
119
+ other.value == value &&
120
+ other.is_placeholder == is_placeholder &&
121
+ other.placeholder_number == placeholder_number
122
+ end
123
+ alias_method :eql?, :==
124
+ end
125
+
126
+ class SQLQueryResult
127
+ def self.from_json(data)
128
+ new(
129
+ kind: data["kind"].to_sym,
130
+ tables: data["tables"].map { |value| Table.from_json(value) },
131
+ filter_columns: data["filters"].map { |value| FilterColumn.from_json(value) },
132
+ insert_columns: data["insert_columns"]&.map do |values|
133
+ values.map { |value| InsertColumn.from_json(value) }
134
+ end
135
+ )
136
+ end
137
+
138
+ # @param kind [:select, :insert, :update, :delete]
139
+ # @param tables [Array<Aikido::Zen::IDOR::Table>]
140
+ # @param filter_columns [Array<Aikido::Zen::IDOR::FilterColumn>]
141
+ # @param insert_columns [Array<Array<Aikido::Zen::IDOR::InsertColumn>>, nil]
142
+ def initialize(kind:, tables:, filter_columns:, insert_columns: nil)
143
+ raise ArgumentError, "kind must be one of :select, :insert, :update, or :delete" unless [:select, :insert, :update, :delete].include?(kind)
144
+
145
+ @kind = kind
146
+ @tables = tables
147
+ @filter_columns = filter_columns
148
+ @insert_columns = insert_columns
149
+ end
150
+
151
+ # @return [:select, :insert, :update, :delete]
152
+ attr_accessor :kind
153
+
154
+ # @return [Array<Aikido::Zen::IDOR::Table>]
155
+ attr_accessor :tables
156
+
157
+ # @return [Array<Aikido::Zen::IDOR::FilterColumn>]
158
+ attr_accessor :filter_columns
159
+
160
+ # @return [Array<Array<Aikido::Zen::IDOR::InsertColumn>>, nil]
161
+ attr_accessor :insert_columns
162
+
163
+ def ==(other)
164
+ other.is_a?(self.class) &&
165
+ other.kind == kind &&
166
+ other.tables == tables &&
167
+ other.filter_columns == filter_columns &&
168
+ other.insert_columns == insert_columns
169
+ end
170
+ alias_method :eql?, :==
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module IDOR
5
+ class Error < StandardError
6
+ end
7
+
8
+ class Protector
9
+ # @api private
10
+ # Visible for testing.
11
+ attr_accessor :cache
12
+
13
+ def initialize(config: Aikido::Zen.config)
14
+ @config = config
15
+
16
+ @cache = CappedMap.new(@config.idor_max_cache_entries, mode: :lru)
17
+ end
18
+
19
+ # @param sql [String]
20
+ # @param dialect_name [Symbol]
21
+ # @param params [Array, nil]
22
+ # @param context [Aikido::Zen::Context]
23
+ # @raise [Aikido::Zen::IDOR::Error, Aikido::Zen::InternalsError]
24
+ def protect(sql, dialect_name, params, context)
25
+ return unless @config.idor_protection_enabled? && context.idor_protection_enabled?
26
+
27
+ tenant_id = context.request.tenant_id
28
+
29
+ if tenant_id.nil?
30
+ raise Aikido::Zen::IDOR::Error.new("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.")
31
+ end
32
+
33
+ dialect = Aikido::Zen::SQL::Dialects.fetch(dialect_name)
34
+
35
+ analysis = analyze(sql, dialect)
36
+
37
+ analysis.each do |query_result|
38
+ if query_result.kind == :insert
39
+ protect_insert(dialect, query_result, tenant_id, params)
40
+ else
41
+ protect_filter(dialect, query_result, tenant_id, params)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # @param sql [String]
49
+ # @param dialect [Aikido::Zen::SQL::Dialects::Dialect]
50
+ # @return [Array<Aikido::Zen::IDOR::SQLQueryResult>]
51
+ # @raise [Aikido::Zen::IDOR::Error, Aikido::Zen::InternalsError]
52
+ def analyze(sql, dialect)
53
+ cache_key = [dialect.internals_key, sql]
54
+
55
+ analysis = @cache[cache_key]
56
+ return analysis if analysis
57
+
58
+ analysis = Internals.idor_analyze_sql(sql, dialect)
59
+
60
+ # :nocov:
61
+ unless analysis
62
+ raise IDOR::Error, "Zen IDOR protection: failed to analyze SQL query"
63
+ end
64
+ # :nocov:
65
+
66
+ if analysis.is_a?(Hash) && analysis["error"]
67
+ raise IDOR::Error, "Zen IDOR protection: #{analysis["error"]}"
68
+ end
69
+
70
+ result = analysis.map do |value|
71
+ Aikido::Zen::IDOR::SQLQueryResult.from_json(value)
72
+ end
73
+
74
+ @cache[cache_key] = result
75
+
76
+ result
77
+ end
78
+
79
+ def protect_insert(dialect, query_result, tenant_id, params)
80
+ query_result.tables.each do |table|
81
+ next if @config.idor_excluded_table_names.include?(table.name)
82
+
83
+ unless query_result.insert_columns
84
+ # INSERT ... SELECT without explicit columns — can't verify tenant column
85
+ raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
86
+ end
87
+
88
+ query_result.insert_columns.each do |row|
89
+ tenant_column = row.find { |column| column.name == @config.idor_tenant_column_name }
90
+
91
+ unless tenant_column
92
+ raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
93
+ end
94
+
95
+ resolved_tenant_id = tenant_column.value
96
+
97
+ if tenant_column.is_placeholder
98
+ resolved_tenant_id = dialect.resolve_placeholder(tenant_column.value, tenant_column.placeholder_number, params)
99
+
100
+ unless resolved_tenant_id
101
+ raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' has a placeholder for '#{@config.idor_tenant_column_name}' that could not be resolved"
102
+ end
103
+ end
104
+
105
+ if resolved_tenant_id.to_s != tenant_id.to_s
106
+ raise IDOR::Error, "Zen IDOR protection: INSERT on table '#{table.name}' sets '#{@config.idor_tenant_column_name}' to '#{resolved_tenant_id}' but tenant ID is '#{tenant_id}'"
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ def protect_filter(dialect, query_result, tenant_id, params)
113
+ query_result.tables.each do |table|
114
+ next if @config.idor_excluded_table_names.include?(table.name)
115
+
116
+ tenant_column = query_result.filter_columns.find do |column|
117
+ next false if column.name != @config.idor_tenant_column_name
118
+
119
+ next column.table_qualifier == table.name || column.table_qualifier == table.alt_name if column.table_qualifier
120
+
121
+ # Unqualified column (e.g. WHERE tenant_id = $1 without table prefix):
122
+ # We can only safely attribute it to the current table when there's
123
+ # exactly one table in the query. With multiple tables, we can't know
124
+ # which table the unqualified column belongs to.
125
+ query_result.tables.size == 1
126
+ end
127
+
128
+ unless tenant_column
129
+ raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'"
130
+ end
131
+
132
+ resolved_tenant_id = tenant_column.value
133
+
134
+ if tenant_column.is_placeholder
135
+ resolved_tenant_id = dialect.resolve_placeholder(tenant_column.value, tenant_column.placeholder_number, params)
136
+
137
+ unless resolved_tenant_id
138
+ raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' has a placeholder for '#{@config.idor_tenant_column_name}' that could not be resolved"
139
+ end
140
+ end
141
+
142
+ if resolved_tenant_id.to_s != tenant_id.to_s
143
+ raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' sets '#{@config.idor_tenant_column_name}' to '#{resolved_tenant_id}' but tenant ID is '#{tenant_id}'"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "idor/analysis_result"
4
+ require_relative "idor/protector"
@@ -26,10 +26,12 @@ module Aikido::Zen
26
26
 
27
27
  names << "#{lib_name}-#{platform}.#{lib_ext}"
28
28
 
29
+ # :nocov:
29
30
  unless platform.version.nil?
30
31
  platform.version = nil
31
32
  names << "#{lib_name}-#{platform}.#{lib_ext}"
32
33
  end
34
+ # :nocov:
33
35
 
34
36
  names
35
37
  end
@@ -51,7 +53,9 @@ module Aikido::Zen
51
53
  # empty
52
54
  end
53
55
  end
56
+ # :nocov:
54
57
  raise LoadError, "Zen could not load its native extension #{libzen_name}"
58
+ # :nocov:
55
59
  end
56
60
 
57
61
  begin
@@ -59,12 +63,16 @@ module Aikido::Zen
59
63
 
60
64
  # @!method self.detect_sql_injection_native(query, input, dialect)
61
65
  # @param (see .detect_sql_injection)
62
- # @returns [Integer] 0 if no injection detected, 1 if an injection was
66
+ # @return [Integer] 0 if no injection detected, 1 if an injection was
63
67
  # detected, 2 if there was an internal error, or 3 if SQL tokenization failed.
64
68
  # @raise [Aikido::Zen::InternalsError] if there's a problem loading or
65
69
  # calling libzen.
66
70
  attach_function :detect_sql_injection_native, :detect_sql_injection,
67
71
  [:pointer, :size_t, :pointer, :size_t, :int], :int
72
+
73
+ attach_function :idor_analyze_sql_native, :idor_analyze_sql_ffi, [:pointer, :size_t, :int], :pointer
74
+
75
+ attach_function :idor_free_string_native, :free_string, [:pointer], :void
68
76
  rescue LoadError, FFI::NotFoundError => err # rubocop:disable Lint/ShadowedException
69
77
  # :nocov:
70
78
 
@@ -76,6 +84,11 @@ module Aikido::Zen
76
84
  raise InternalsError.new(attempt, "loading", libzen_name)
77
85
  end
78
86
 
87
+ def self.idor_analyze_sql(query, *)
88
+ attempt = format("%p for SQL analysis", query)
89
+ raise InternalsError.new(attempt, "loading", libzen_name)
90
+ end
91
+
79
92
  # :nocov:
80
93
  else
81
94
  # Analyzes the SQL query to detect if the provided user input is being
@@ -86,7 +99,7 @@ module Aikido::Zen
86
99
  # @param dialect [Integer, #to_int] the SQL Dialect identifier in libzen.
87
100
  # See {Aikido::Zen::Scanners::SQLInjectionScanner::DIALECTS}.
88
101
  #
89
- # @returns [Boolean]
102
+ # @return [Boolean]
90
103
  # @raise [Aikido::Zen::InternalsError] if there's a problem loading or
91
104
  # calling libzen.
92
105
  def self.detect_sql_injection(query, input, dialect)
@@ -108,6 +121,18 @@ module Aikido::Zen
108
121
 
109
122
  result
110
123
  end
124
+
125
+ def self.idor_analyze_sql(query, dialect)
126
+ query_bytes = encode_safely(query)
127
+ query_ptr = FFI::MemoryPointer.new(:uint8, query_bytes.bytesize)
128
+ query_ptr.put_bytes(0, query_bytes)
129
+
130
+ result_ptr = idor_analyze_sql_native(query_ptr, query_bytes.bytesize, dialect)
131
+ result_json = result_ptr.read_string
132
+ idor_free_string_native(result_ptr)
133
+
134
+ JSON.parse(result_json)
135
+ end
111
136
  end
112
137
 
113
138
  class << self
@@ -39,6 +39,10 @@ module Aikido::Zen
39
39
  end
40
40
 
41
41
  ActiveSupport.on_load(:action_controller) do
42
+ before_action do
43
+ Aikido::Zen.enable_idor_protection if Aikido::Zen.config.idor_protection_enabled?
44
+ end
45
+
42
46
  # Due to how Rails sets up its middleware chain, the routing is evaluated
43
47
  # (and the Request object constructed) in the app that terminates the
44
48
  # chain, so no amount of middleware will be able to access it.
@@ -17,6 +17,12 @@ module Aikido::Zen
17
17
  # @see Aikido::Zen.track_user
18
18
  attr_accessor :actor
19
19
 
20
+ # The current tenant, if set by the host app.
21
+ #
22
+ # @return [Integer, String, nil]
23
+ # @see Aikido::Zen.set_tenant_id
24
+ attr_accessor :tenant_id
25
+
20
26
  def initialize(delegate, config = Aikido::Zen.config, framework:, router:)
21
27
  super(delegate)
22
28
  @config = config
@@ -24,10 +24,7 @@ module Aikido::Zen
24
24
  # @return [Aikido::Zen::Attack, nil] an Attack if any user input is
25
25
  # detected to be attempting a SQL injection, or nil if this is safe.
26
26
  def self.call(query:, dialect:, scan:, sink:, context:, operation:)
27
- dialect = DIALECTS.fetch(dialect) do
28
- Aikido::Zen.config.logger.warn "Unknown SQL dialect #{dialect.inspect}"
29
- DIALECTS[:common]
30
- end
27
+ dialect = Aikido::Zen::SQL::Dialects.fetch(dialect)
31
28
 
32
29
  context.payloads.each do |payload|
33
30
  scanner = new(query, payload.value.to_s, dialect)
@@ -93,23 +90,6 @@ module Aikido::Zen
93
90
 
94
91
  raise err
95
92
  end
96
-
97
- # @api private
98
- Dialect = Struct.new(:name, :internals_key, keyword_init: true) do
99
- alias_method :to_s, :name
100
- alias_method :to_int, :internals_key
101
- end
102
-
103
- # Maps easy-to-use Symbols to a struct that keeps both the name and the
104
- # internal identifier used by libzen.
105
- #
106
- # @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
107
- DIALECTS = {
108
- common: Dialect.new(name: "SQL", internals_key: 0),
109
- mysql: Dialect.new(name: "MySQL", internals_key: 8),
110
- postgresql: Dialect.new(name: "PostgreSQL", internals_key: 9),
111
- sqlite: Dialect.new(name: "SQLite", internals_key: 12)
112
- }
113
93
  end
114
94
  end
115
95
  end
@@ -18,8 +18,32 @@ module Aikido::Zen
18
18
  ::Mysql2::Client.class_eval do
19
19
  extend Sinks::DSL
20
20
 
21
- sink_before :query do |sql|
22
- Helpers.scan(sql, "query")
21
+ presafe_sink_before :query do |sql|
22
+ Sinks::DSL.safe do
23
+ Helpers.scan(sql, "query")
24
+ end
25
+
26
+ Aikido::Zen.idor_protect(sql, :mysql)
27
+ end
28
+
29
+ presafe_sink_after :prepare do |result, sql|
30
+ result.aikido_idor_sql = sql
31
+ end
32
+ end
33
+
34
+ ::Mysql2::Statement.class_eval do
35
+ extend Sinks::DSL
36
+
37
+ attr_accessor :aikido_idor_sql
38
+
39
+ presafe_sink_before :execute do |*args, **kwargs|
40
+ sql = aikido_idor_sql
41
+
42
+ Sinks::DSL.safe do
43
+ Helpers.scan(sql, "query")
44
+ end
45
+
46
+ Aikido::Zen.idor_protect(sql, :mysql, args)
23
47
  end
24
48
  end
25
49
  end
@@ -44,31 +44,63 @@ module Aikido::Zen
44
44
 
45
45
  [
46
46
  :send_query,
47
- :exec,
48
- :sync_exec,
47
+ :exec, # also known as: async_exec
49
48
  :async_exec,
49
+ :sync_exec
50
+ ].each do |method_name|
51
+ presafe_sink_before method_name do |sql|
52
+ Helpers.safe do
53
+ Helpers.scan(sql, method_name)
54
+ end
55
+
56
+ Aikido::Zen.idor_protect(sql, :postgresql)
57
+ end
58
+ end
59
+
60
+ [
50
61
  :send_query_params,
51
- :exec_params,
52
- :sync_exec_params,
53
- :async_exec_params
62
+ :exec_params, # also known as: async_exec_params
63
+ :async_exec_params,
64
+ :sync_exec_params
54
65
  ].each do |method_name|
55
- presafe_sink_before method_name do |query|
66
+ presafe_sink_before method_name do |sql, params|
56
67
  Helpers.safe do
57
- Helpers.scan(query, method_name)
68
+ Helpers.scan(sql, method_name)
58
69
  end
70
+
71
+ Aikido::Zen.idor_protect(sql, :postgresql, params)
59
72
  end
60
73
  end
61
74
 
75
+ def aikido_idor_prepared_statements
76
+ @aikido_idor_prepared_statements ||= {}
77
+ end
78
+
62
79
  [
63
80
  :send_prepare,
64
- :prepare,
81
+ :prepare, # also known as: async_prepare
65
82
  :async_prepare,
66
83
  :sync_prepare
67
84
  ].each do |method_name|
68
- presafe_sink_before method_name do |_, query|
85
+ presafe_sink_before method_name do |statement_name, sql|
86
+ aikido_idor_prepared_statements[statement_name] = sql
87
+ end
88
+ end
89
+
90
+ [
91
+ :send_query_prepared,
92
+ :exec_prepared, # also known as: async_exec_prepared
93
+ :async_exec_prepared,
94
+ :sync_exec_prepared
95
+ ].each do |method_name|
96
+ presafe_sink_before method_name do |statement_name, params|
97
+ sql = aikido_idor_prepared_statements[statement_name]
98
+
69
99
  Helpers.safe do
70
- Helpers.scan(query, method_name)
100
+ Helpers.scan(sql, method_name)
71
101
  end
102
+
103
+ Aikido::Zen.idor_protect(sql, :postgresql, params)
72
104
  end
73
105
  end
74
106
  end
@@ -22,19 +22,63 @@ module Aikido::Zen
22
22
  ::SQLite3::Database.class_eval do
23
23
  extend Sinks::DSL
24
24
 
25
- private
25
+ [
26
+ :execute,
27
+ :execute_batch
28
+ ].each do |method_name|
29
+ presafe_sink_before method_name do |sql, bind_vars|
30
+ Sinks::DSL.safe do
31
+ Helpers.scan(sql, "database.execute")
32
+ end
33
+
34
+ Aikido::Zen.idor_protect(sql, :sqlite, bind_vars)
35
+ end
36
+ end
26
37
 
27
38
  # SQLite3::Database#exec_batch is an internal native private method.
28
- sink_before :exec_batch do |sql|
29
- Helpers.scan(sql, "exec_batch")
39
+ presafe_sink_before :exec_batch do |sql, *args, **kwargs|
40
+ Sinks::DSL.safe do
41
+ Helpers.scan(sql, "exec_batch")
42
+ end
43
+
44
+ Aikido::Zen.idor_protect(sql, :sqlite)
45
+ end
46
+
47
+ alias_method :prepare__internal_for_aikido_zen, :prepare
48
+
49
+ def prepare(*args, **kwargs, &blk)
50
+ sql, = args
51
+
52
+ Sinks::DSL.safe do
53
+ Helpers.scan(sql, "statement.execute")
54
+ end
55
+
56
+ unless blk
57
+ result = prepare__internal_for_aikido_zen(*args, **kwargs)
58
+ result.aikido_idor_sql = sql
59
+ return result
60
+ end
61
+
62
+ prepare__internal_for_aikido_zen(*args, **kwargs) do |stmt|
63
+ stmt.aikido_idor_sql = sql
64
+ blk.call(stmt)
65
+ end
30
66
  end
31
67
  end
32
68
 
33
69
  ::SQLite3::Statement.class_eval do
34
70
  extend Sinks::DSL
35
71
 
36
- sink_before :initialize do |_db, sql|
37
- Helpers.scan(sql, "statement.execute")
72
+ attr_accessor :aikido_idor_sql
73
+
74
+ presafe_sink_before :execute do |*bind_vars|
75
+ sql = aikido_idor_sql
76
+
77
+ Sinks::DSL.safe do
78
+ Helpers.scan(sql, "statement.execute")
79
+ end
80
+
81
+ Aikido::Zen.idor_protect(sql, :sqlite, bind_vars)
38
82
  end
39
83
  end
40
84
  end
@@ -18,8 +18,12 @@ module Aikido::Zen
18
18
  ::Trilogy.class_eval do
19
19
  extend Sinks::DSL
20
20
 
21
- sink_before :query do |query|
22
- Helpers.scan(query, "query")
21
+ presafe_sink_before :query do |sql|
22
+ Sinks::DSL.safe do
23
+ Helpers.scan(sql, "query")
24
+ end
25
+
26
+ Aikido::Zen.idor_protect(sql, :mysql)
23
27
  end
24
28
  end
25
29
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aikido::Zen
4
+ module SQL
5
+ module Dialects
6
+ # @api private
7
+ Dialect = Struct.new(:name, :internals_key, :placeholder_resolver, keyword_init: true) do
8
+ alias_method :to_s, :name
9
+ alias_method :to_int, :internals_key
10
+
11
+ def resolve_placeholder(*args, **kwargs)
12
+ placeholder_resolver.call(*args, **kwargs)
13
+ end
14
+ end
15
+
16
+ # @param value [String]
17
+ # @param placeholder_number [Integer, nil]
18
+ # @param params [Array<Object>, nil]
19
+ # @return [Object]
20
+ def self.common_placeholder_resolver(value, placeholder_number, params)
21
+ return nil unless params
22
+
23
+ params[placeholder_number] unless placeholder_number.nil?
24
+ end
25
+
26
+ # @param value [String]
27
+ # @param placeholder_number [Integer, nil]
28
+ # @param params [Array<Object>, nil]
29
+ # @return [Object]
30
+ def self.postgresql_placeholder_resolver(value, placeholder_number, params)
31
+ return nil unless params
32
+
33
+ match = value.match(/^\$(\d+)$/)
34
+ if match
35
+ index = match[1].to_i - 1
36
+ return if index < 0
37
+
38
+ params[index]
39
+ end
40
+ end
41
+
42
+ # @param value [String]
43
+ # @param placeholder_number [Integer, nil]
44
+ # @param params [Array<Object>, nil]
45
+ # @return [Object]
46
+ def self.sqlite_placeholder_resolver(value, placeholder_number, params)
47
+ return nil unless params
48
+
49
+ return params[placeholder_number] unless placeholder_number.nil?
50
+
51
+ case value
52
+ when /^\?(\d+)$/
53
+ match = Regexp.last_match
54
+
55
+ index = match[1].to_i - 1
56
+ return if index < 0
57
+
58
+ params[index]
59
+ when /^[:@$]([A-Za-z_][A-Za-z0-9_]*)$/
60
+ match = Regexp.last_match
61
+
62
+ key = match[1]
63
+
64
+ params.flatten.each do |param|
65
+ if Hash === param
66
+ param.each do |param_key, param_value|
67
+ return param_value if param_key.to_s == key
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ # Maps easy-to-use Symbols to a struct that keeps both the name and the
75
+ # internal identifier used by libzen.
76
+ #
77
+ # @see https://github.com/AikidoSec/zen-internals/blob/main/src/sql_injection/helpers/select_dialect_based_on_enum.rs
78
+ DIALECTS = {
79
+ common: Dialect.new(
80
+ name: "SQL",
81
+ internals_key: 0,
82
+ placeholder_resolver: method(:common_placeholder_resolver)
83
+ ),
84
+ mysql: Dialect.new(
85
+ name: "MySQL",
86
+ internals_key: 8,
87
+ placeholder_resolver: method(:common_placeholder_resolver)
88
+ ),
89
+ postgresql: Dialect.new(
90
+ name: "PostgreSQL",
91
+ internals_key: 9,
92
+ placeholder_resolver: method(:postgresql_placeholder_resolver)
93
+ ),
94
+ sqlite: Dialect.new(
95
+ name: "SQLite",
96
+ internals_key: 12,
97
+ placeholder_resolver: method(:sqlite_placeholder_resolver)
98
+ )
99
+ }.freeze
100
+
101
+ # @param dialect [Symbol]
102
+ # @return [Aikido::Zen::SQL::Dialects::Dialect]
103
+ def self.fetch(dialect)
104
+ DIALECTS.fetch(dialect, DIALECTS[:common])
105
+ end
106
+ end
107
+ end
108
+ end
@@ -2,9 +2,9 @@
2
2
 
3
3
  module Aikido
4
4
  module Zen
5
- VERSION = "1.3.1"
5
+ VERSION = "1.4.0"
6
6
 
7
7
  # The version of libzen_internals that we build against.
8
- LIBZEN_VERSION = "0.1.60"
8
+ LIBZEN_VERSION = "0.1.61"
9
9
  end
10
10
  end
data/lib/aikido/zen.rb CHANGED
@@ -26,6 +26,8 @@ require_relative "zen/outbound_connection"
26
26
  require_relative "zen/runtime_settings"
27
27
  require_relative "zen/rate_limiter"
28
28
  require_relative "zen/attack_wave"
29
+ require_relative "zen/sql"
30
+ require_relative "zen/idor"
29
31
  require_relative "zen/scanners"
30
32
 
31
33
  module Aikido
@@ -213,17 +215,78 @@ module Aikido
213
215
  alias_method :set_user, :track_user
214
216
  end
215
217
 
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
+ # @return [Aikido::Zen::IDOR::Protector]
224
+ def self.idor_protector
225
+ @idor_protector ||= IDOR::Protector.new
226
+ end
227
+
228
+ # @param sql [String]
229
+ # @param dialect [Symbol]
230
+ # @param params [Array, nil]
231
+ # @return [void]
232
+ # @raise [Aikido::Zen::IDOR::Error]
233
+ def self.idor_protect(sql, dialect_name, params = nil)
234
+ context = current_context
235
+ return unless context
236
+
237
+ idor_protector.protect(sql, dialect_name, params, context)
238
+ end
239
+
240
+ # Enable IDOR protection for the current context.
241
+ #
242
+ # @return [void]
243
+ def self.enable_idor_protection
244
+ context = current_context
245
+ return unless context
246
+
247
+ context.idor_protection_enabled = true
248
+ end
249
+
250
+ # Set the tenant ID for the current request.
251
+ #
252
+ # @param tenant_id [Integer, String, nil]
253
+ # @return [void]
254
+ def self.set_tenant_id(tenant_id)
255
+ context = current_context
256
+ return unless context
257
+
258
+ context.request.tenant_id = tenant_id
259
+ end
260
+
261
+ # Execute a block with the IDOR protection disabled.
262
+ #
263
+ # @yield the block to execute with the IDOR protection disabled.
264
+ # @return [Object] the result of the block
265
+ # @raise [ArgumentError] if no block is given
266
+ def self.without_idor_protection
267
+ raise ArgumentError, "block required" unless block_given?
268
+
269
+ context = current_context
270
+
271
+ if context
272
+ begin
273
+ original_idor_protection_enabled = context.idor_protection_enabled
274
+ context.idor_protection_enabled = false
275
+ yield
276
+ ensure
277
+ context.idor_protection_enabled = original_idor_protection_enabled
278
+ end
279
+ else
280
+ yield
281
+ end
282
+ end
283
+
216
284
  # Marks that the Zen middleware was installed properly
217
285
  # @return void
218
286
  def self.middleware_installed!
219
287
  collector.middleware_installed!
220
288
  end
221
289
 
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
-
227
290
  # @!visibility private
228
291
  # Load all sources.
229
292
  #
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aikido-zen
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.1
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aikido Security
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-07 00:00:00.000000000 Z
11
+ date: 2026-05-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: concurrent-ruby
@@ -87,6 +87,7 @@ files:
87
87
  - benchmarks/rails7.1_sql_injection.js
88
88
  - docs/banner.svg
89
89
  - docs/config.md
90
+ - docs/idor-protection.md
90
91
  - docs/invalid-sql-queries.md
91
92
  - docs/proxy.md
92
93
  - docs/rails.md
@@ -122,6 +123,9 @@ files:
122
123
  - lib/aikido/zen/errors.rb
123
124
  - lib/aikido/zen/event.rb
124
125
  - lib/aikido/zen/helpers.rb
126
+ - lib/aikido/zen/idor.rb
127
+ - lib/aikido/zen/idor/analysis_result.rb
128
+ - lib/aikido/zen/idor/protector.rb
125
129
  - lib/aikido/zen/internals.rb
126
130
  - lib/aikido/zen/middleware/allowed_address_checker.rb
127
131
  - lib/aikido/zen/middleware/attack_protector.rb
@@ -192,6 +196,7 @@ files:
192
196
  - lib/aikido/zen/sinks/trilogy.rb
193
197
  - lib/aikido/zen/sinks/typhoeus.rb
194
198
  - lib/aikido/zen/sinks_dsl.rb
199
+ - lib/aikido/zen/sql.rb
195
200
  - lib/aikido/zen/synchronizable.rb
196
201
  - lib/aikido/zen/system_info.rb
197
202
  - lib/aikido/zen/version.rb