`rails query`コマンドの書き込み保護バイパスと単一レコード返却のバグを修正
rails queryコマンドに存在した2つのバグが修正されました。1つはActiveRecord式の評価時に書き込み保護が適用されず、LLMによるdelete_all等の破壊的操作が実行可能だった脆弱性(#57420)、もう1つは単一レコードを返す式で有用な情報が得られなかった問題(#57419)です。
背景
rails queryはActiveRecord式またはRaw SQLを実行してJSON形式で結果を返すコマンドで、LLMとの統合を想定した設計ですが、その用途ゆえに2つの問題が顕在化しました。
第一の問題は、書き込み保護のバイパスです。execute_arメソッド内のeval呼び出しはwith_readonly_connectionブロックの外で実行されており、User.delete_allやuser.destroyといった破壊的なActiveRecord式をガードなしで実行できる状態でした。読み取り専用ガードはActiveRecord::Relationが返ってきた後にconnection.select_allを呼ぶ経路のみに適用されており、eval自体のサイドエフェクトは保護対象外でした。Issue報告者の環境では、LLMが本番環境に対してUser.delete_allを実行し、FK制約により辛うじてデータ消失を免れたケースが報告されています。
第二の問題は、単一レコードを返す式での出力の劣化です。User.find(1)のようにActiveRecord::Baseインスタンスを返す式を実行すると、elseブランチに落ち、結果が"#<User:0x00007fb9de746518>"のようなオブジェクトのinspect文字列として返されていました。同様にUser.first(2)のようにActiveRecord::Baseインスタンスの配列を返す場合も、各要素がinspect文字列になるという問題がありました。
技術的な変更
変更はrailties/lib/rails/commands/query/query_command.rbに集中しており、書き込み保護の適用範囲の拡張と結果型の処理追加の2点で構成されます。
書き込み保護の修正として、execute_arとrun_explainの両メソッドでeval呼び出し自体をwith_readonly_connectionブロックで包むよう変更されました。
変更前:
def execute_ar(expression:, page:, per:)
result = eval(expression, TOPLEVEL_BINDING, "(query)", 1)
# ...
end
変更後:
def execute_ar(expression:, page:, per:)
result = with_readonly_connection { eval(expression, TOPLEVEL_BINDING, "(query)", 1) }
# ...
end
run_explainのevalも同様にwith_readonly_connectionで包まれています。これにより、評価対象の式がどのような型を返すかに関わらず、eval中に発生するすべてのDB書き込みが抑止されます。
単一レコード返却の修正として、case result文にActiveRecord::Baseのwhen節が追加されました。また、Arrayブランチでは先頭要素がActiveRecord::Baseのインスタンスかどうかを確認し、そうであれば各レコードのattributesを展開してカラム名と値の配列を構築するよう変更されています。
when ActiveRecord::Base
attrs = result.attributes
{ columns: attrs.keys, rows: [ attrs.values ], sql: expression, truncated: false }
when Array
if result.first.is_a?(ActiveRecord::Base)
columns = result.first.attributes.keys
rows = result.map { |record| record.attributes.values }
{ columns: columns, rows: rows, sql: expression, truncated: false }
else
rows = result.map { |val| Array(val) }
cols = Array.new(rows.first&.length.to_i) { |i| "column_#{i}" }
{ columns: cols, rows: rows, sql: expression, truncated: false }
end
テストはrailties/test/commands/query_test.rbに52行追加されており、Post.delete_all・Post.first.update!・explainでの書き込み防止、およびPost.firstでの単一レコード取得が正しくカラム付きで返ることを検証しています。
設計判断
eval自体をwith_readonly_connectionで包む方式が採用されています。Issue #57420ではActiveRecord::Base.while_preventing_writesを使う案も提案されていましたが、with_readonly_connectionでevalを囲む最小限の変更が選択されました。なお、マルチDB構成ではwith_readonly_connectionがActiveRecord::Baseのコネクションクラスを基準にするため、異なるコネクションクラスを使うモデルへの完全な保護は別途検討が必要な点はIssueでも言及されています。
ActiveRecord::Baseのwhen節はActiveRecord::Relationのwhen節より後に配置されており、Relationがそのままヒットしないよう型の優先順位が維持されています。Arrayブランチでのresult.first.is_a?(ActiveRecord::Base)による分岐は、既存のプリミティブ配列処理との後方互換性を保ちながらARインスタンス配列を正しく扱うための判断です。
まとめ
この修正により、rails queryがLLMから利用される際にevalの評価タイミングで書き込みが実行されるという本質的な脆弱性が解消されました。evalをwith_readonly_connectionで包むという局所的な変更で根本原因に対処しつつ、単一レコードのシリアライズ欠落も同時に修正したことで、コマンドの実用性が大幅に向上しています。