diff --git a/lib/aikido/zen/idor/analysis_result.rb b/lib/aikido/zen/idor/analysis_result.rb index 96c04740..b7a7fde2 100644 --- a/lib/aikido/zen/idor/analysis_result.rb +++ b/lib/aikido/zen/idor/analysis_result.rb @@ -37,7 +37,8 @@ def self.from_json(data) name: data["column"], value: data["value"], is_placeholder: data["is_placeholder"], - placeholder_number: data["placeholder_number"] + placeholder_number: data["placeholder_number"], + is_where: data["is_where"] ) end @@ -46,12 +47,14 @@ def self.from_json(data) # @param value [String] # @param is_placeholder [Boolean] # @param placeholder_number [Integer, nil] - def initialize(table_qualifier:, name:, value:, is_placeholder:, placeholder_number: nil) + # @param is_where [Boolean, nil] Whether this equality originates from a WHERE clause (row-restricting context) + def initialize(table_qualifier:, name:, value:, is_placeholder:, placeholder_number: nil, is_where: nil) @table_qualifier = table_qualifier @name = name @value = value @is_placeholder = is_placeholder @placeholder_number = placeholder_number + @is_where = is_where end # @return [String, nil] @@ -69,13 +72,18 @@ def initialize(table_qualifier:, name:, value:, is_placeholder:, placeholder_num # @return [Integer, nil] attr_accessor :placeholder_number + # @return [Boolean, nil] Whether this equality originates from a WHERE clause (row-restricting context). + # nil indicates the native analyzer does not provide this information (older version). + attr_accessor :is_where + def ==(other) other.is_a?(self.class) && other.table_qualifier == table_qualifier && other.name == name && other.value == value && other.is_placeholder == is_placeholder && - other.placeholder_number == placeholder_number + other.placeholder_number == placeholder_number && + other.is_where == is_where end alias_method :eql?, :== end diff --git a/lib/aikido/zen/idor/protector.rb b/lib/aikido/zen/idor/protector.rb index a0098e0f..127355bb 100644 --- a/lib/aikido/zen/idor/protector.rb +++ b/lib/aikido/zen/idor/protector.rb @@ -129,6 +129,16 @@ def protect_filter(dialect, query_result, tenant_id, params) raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' is missing column '#{@config.idor_tenant_column_name}'" end + # Verify that the tenant equality originates from a WHERE clause (row-restricting context). + # Equalities in SELECT projections, HAVING clauses, or other non-filtering contexts + # do not restrict the result set and must be rejected to prevent IDOR bypasses. + # The is_where field is provided by libzen >= 0.1.75. When present and false, + # we reject the query. When nil (older libzen), we allow it for backward compatibility + # but log a warning that the query cannot be fully validated. + if tenant_column.is_where == false + raise IDOR::Error, "Zen IDOR protection: query on table '#{table.name}' has '#{@config.idor_tenant_column_name}' equality in a non-filtering context (e.g., SELECT projection, HAVING clause)" + end + resolved_tenant_id = tenant_column.value if tenant_column.is_placeholder