`write_query?` misclassifies a stacked write statement as a read (no end-anchor in the regex)
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:
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.
Cause: build_read_query_regexp only checks that the string starts with a read keyword, with no end-anchor:
# 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
Anything after the first recognized keyword is never checked. Suggested fix: add an end-anchor (or reject a bare top-level ; followed by further content) so the whole string is validated, not just the prefix.
System configuration
Rails version: reproduced on main (a63debae6081630294cfe21554984abfbd346f43), 8.1.3.1, and 7.2.2.2
Ruby version: 3.3.8
Source: rails/rails