#58778·rails

`write_query?` 将堆叠的写入语句错误地归类为读取(正则表达式中没有结束锚)

作者: mester-rebo创建于 2026年9月15日更新于 2026年9月17日

Steps to reproduce

ruby
# frozen_string_literal: true
require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"
  gem "rails"
  gem "sqlite3"
end
require "active_record/railtie"
require "minitest/autorun"

ENV["DATABASE_URL"] = "sqlite3::memory:"
class TestApp < Rails::Application
  config.load_defaults Rails::VERSION::STRING.to_f
  config.eager_load = false
  config.logger = Logger.new($stdout)
  config.secret_key_base = "secret_key_base"
end
Rails.application.initialize!
class BugTest < ActiveSupport::TestCase
  def test_write_query_misclassifies_a_stacked_statement_as_a_read
    payload = "SELECT 1; DROP TABLE users; --"
    assert ActiveRecord::Base.connection.write_query?(payload),
      "write_query? should classify this as a write (it contains a stacked DROP TABLE), " \
      "but it returns false because build_read_query_regexp only checks the prefix of the string."
  end
end
On PostgreSQL specifically, this isn't just a misclassification — sending the same string through `connection.execute` with no bind params (libpq's simple query protocol runs every `;`-separated statement) while wrapped in `ActiveRecord::Base.while_preventing_writes` actually executes the `DROP TABLE`, with no `ReadOnlyError` raised:
```ruby
ActiveRecord::Base.while_preventing_writes do
  ActiveRecord::Base.connection.execute("SELECT 1; DROP TABLE users; --")
end

Verified live against a real PostgreSQL 17 database — table was gone afterward, no error. Not reproducible on SQLite3 (its default execute path only runs the first statement).

Expected behavior

write_query?("SELECT 1; DROP TABLE users; --") returns true. Under while_preventing_writes, executing it raises ActiveRecord::ReadOnlyError before anything runs.

Actual behavior

write_query? returns false. No error is raised. On PostgreSQL, the DROP TABLE executes. 原因: build_read_query_regexp 只检查字符串是否以读取关键字开始,没有结尾标记:

ruby
# activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L103-L110
def self.build_read_query_regexp(*parts) # :nodoc:
  parts += DEFAULT_READ_QUERY
  parts = parts.map { |part| /#{part}/i }
  /\A(?:[(\s]|#{COMMENT_REGEX})*#{Regexp.union(*parts)}/   # no \z
end
https://GitHub.com/rails/rails/blob/a63debae6081630294cfe21554984abfbd346f43/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L103-L110
任何后面的已识别关键字都不会被检查。建议的修复:添加结尾标记(或拒绝一个仅包含顶级 `;` 且后面跟着其他内容的字符串),这样整个字符串都会被检查。