`write_query?` 将堆叠的写入语句错误地归类为读取(正则表达式中没有结束锚)
作者: mester-rebo创建于 2026年9月15日更新于 2026年9月17日
Steps to reproduce
# 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; --")
endVerified 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 只检查字符串是否以读取关键字开始,没有结尾标记:
# 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
任何后面的已识别关键字都不会被检查。建议的修复:添加结尾标记(或拒绝一个仅包含顶级 `;` 且后面跟着其他内容的字符串),这样整个字符串都会被检查。内容来源: rails/rails