#58778·rails

`write_query?` misclassifies a stacked write statement as a read (no end-anchor in the regex)

Author: mester-reboCreated Sep 15, 2026Updated Sep 17, 2026

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

https://github.com/rails/rails/blob/a63debae6081630294cfe21554984abfbd346f43/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L103-L110

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