Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix SQL injection in Ruby on Rails

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Pass values to Active Record as hash conditions or placeholders: where(customers: { name: name }, status: status) or where("customers.name = ?", name). Never interpolate request data into where, order or Arel.sql strings. Map the requested sort onto an allowlist of Arel attributes and answer 400 for anything else; order() accepts any column name.

Stack
  • Language Ruby
  • Runtime Ruby 4.0.7
  • Framework Ruby on Rails 8.1.4 (Active Record over SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 26 KB
On this page

Steps

  1. Find SQL fragments that can reach request data: where, order, reorder, group, having, select, joins, from, pluck, find_by_sql, update_all and connection.execute with "#{...}" interpolation, and every Arel.sql() call.
  2. Use hash conditions (where(status: status), where(customers: { name: name })) or placeholders (where("customers.name = ?", name), where("name = :name", name: name)). Active Record sends the values as bind parameters or quotes them for the adapter; the SQL text stays yours.
  3. When you must build a fragment yourself, use sanitize_sql_array(["customers.name = ?", name]) or find_by_sql([sql, *values]); sanitize_sql_like only escapes LIKE wildcards.
  4. For the sort column and direction, map each public option to an Arel attribute (Customer.arel_table[:name]) and call asc or desc on it; answer 400 for anything else.
  5. Read request values with params.expect(:customer), so a missing value, an array or a hash answers 400 instead of reaching the query.
  6. Add regression tests with this card's payloads (tautology, UNION, comment, sort expression, hidden column) next to functional tests for O'Brien and non-ASCII names, and run Brakeman in CI.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableapp/queries/order_queries/vulnerable.rbRuby
def self.orders_for_customer(customer_name, status)
  Order.joins(:customer)
       .where("customers.name = '#{customer_name}' AND orders.status = '#{status}'")
       .select(:id, :status, :total_cents, :created_at)
       .order(:id)
end

With IMPL=vulnerable, 8 of 12 tests fail (6 exploit tests).

The fix

Fixedapp/queries/order_queries/fixed.rbRuby
def self.orders_for_customer(customer_name, status)
  # The SQL text never changes: SQLite receives both values as parameters.
  Order.joins(:customer)
       .where(customers: { name: customer_name }, status: status)
       .select(:id, :status, :total_cents, :created_at)
       .order(:id)
end

With IMPL=fixed, 12 of 12 tests pass.

The fix: sort allowlist

Fixedapp/queries/order_queries/fixed.rbRuby
# Map each public sort option to a column. order() would accept any column name the caller
# sends, including customers.email, which the API never returns.
SORT_COLUMNS = {
  "created_at" => Order.arel_table[:created_at],
  "total_cents" => Order.arel_table[:total_cents],
  "customer" => Customer.arel_table[:name]
}.freeze

def self.search_orders(status, sort, direction)
  column = SORT_COLUMNS[sort]
  unless column && %w[asc desc].include?(direction)
    raise UnsupportedSort, "unsupported sort column or direction"
  end

  Order.joins(:customer)
       .where(status: status)
       .select("orders.*, customers.name AS customer")
       .order(direction == "desc" ? column.desc : column.asc, :id)
end

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixapp/queries/order_queries/wrong.rbRuby
def self.orders_for_customer(customer_name, status)
  # sanitize_sql_like escapes %, _ and the escape character. A quote passes through unchanged.
  name = Order.sanitize_sql_like(customer_name)
  state = Order.sanitize_sql_like(status)
  Order.joins(:customer)
       .where("customers.name = '#{name}' AND orders.status = '#{state}'")
       .select(:id, :status, :total_cents, :created_at)
       .order(:id)
end

With IMPL=wrong, 6 of 12 tests fail (4 exploit tests).

A fix that still fails: relying on order()'s raw-SQL check

Wrong fixapp/queries/order_queries/wrong.rbRuby
def self.search_orders(status, sort, direction)
  # Without Arel.sql(), order() raises for anything that is not a column name and direction:
  # it refuses a CASE expression, but takes customers.email or a column that does not exist.
  Order.joins(:customer)
       .where(status: status)
       .select("orders.*, customers.name AS customer")
       .order("#{sort} #{direction}", :id)
rescue ActiveRecord::UnknownAttributeReference
  raise UnsupportedSort, "unsupported sort column or direction"
end

With IMPL=wrong, 6 of 12 tests fail (4 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtest/orders_test.rbRuby
# Exploit: each payload must stay data and leave the query's meaning unchanged. Active Record
# wraps a string condition in parentheses, so the payloads close one.

test "exploit: tautology does not return other customers' orders" do
  assert_equal [], order_ids("/orders", customer: "' OR '1'='1", status: "shipped")
end

test "exploit: union cannot read another customer's orders" do
  customer = "') UNION SELECT id, status, total_cents, created_at FROM orders WHERE customer_id = 2 --"
  assert_equal [], order_ids("/orders", customer:, status: "shipped")
end

test "exploit: comment cannot cut off the status filter" do
  assert_equal [], order_ids("/orders", customer: "Alice Martin')--", status: "shipped")
end

test "exploit: sort expression is rejected, not used as an oracle" do
  # Row order would reveal whether each customer's hidden email starts with "b".
  sort = "CASE WHEN substr(customers.email, 1, 1) = char(98) THEN 0 ELSE 1 END"
  get "/admin/orders", params: { status: "shipped", sort:, dir: "asc" }
  assert_response :bad_request
end

test "exploit: direction cannot add a second sort expression" do
  dir = "ASC, CASE WHEN substr(customers.email, 1, 1) = char(98) THEN 0 ELSE 1 END"
  get "/admin/orders", params: { status: "shipped", sort: "created_at", dir: }
  assert_response :bad_request
end

test "exploit: sort by a column the API never returns is rejected" do
  # order() accepts any column name; sorting by the hidden email leaks its order.
  get "/admin/orders", params: { status: "shipped", sort: "customers.email", dir: "asc" }
  assert_response :bad_request
end

Pitfalls

  • Active Record wraps a string condition in parentheses: where("customers.name = '#{name}'") becomes WHERE (customers.name = '...'), so the UNION and comment payloads close the parenthesis too (') UNION ..., ')--). A payload written for another framework may fail with a syntax error and make the vulnerable code look safe.
  • Since Rails 6.1, order() raises ActiveRecord::UnknownAttributeReference for an argument that does not look like a column name and direction. It still accepts any column name, including customers.email from a joined table, and a column that does not exist becomes a 500. This card's wrong implementation relies on that check, and sorting by the hidden email leaks its order.
  • Arel.sql() tells Active Record that a string is trusted SQL and turns its raw-SQL check off. Never pass request data through it.
  • sanitize_sql_like escapes %, _ and the escape character for LIKE patterns and leaves quotes alone: in this card's wrong implementation O'Brien breaks the query and ' OR '1'='1 returns every shipped order. Its right use is inside a bound value: where("name LIKE ?", "%#{Customer.sanitize_sql_like(q)}%").
  • select, group, having, joins, from, pluck and update_all take SQL fragments too, and find_by_sql takes raw SQL; pass arrays with placeholders where the method accepts them.
  • Keep consider_all_requests_local off in production (it is in production.rb) so a database error is the generic 500 page, not the SQL.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs sql-injection/ruby-rails does this for you, in the pinned Ruby container).
  2. Against your own endpoint, send customer=' OR '1'='1 and customer=Alice Martin')-- and expect no extra rows; send a CASE expression or customers.email as the sort and expect 400.
  3. Run Brakeman: it reported both interpolated where() strings and the Arel.sql() order here, but not order("#{sort} #{direction}") without Arel.sql(). Semgrep's tainted-sql-string rule only follows params within one file.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix SQL injection in Ruby on Rails
ToolRuleWhat it found here
Semgrepruby.rails.security.injection.tainted-sql-string.tainted-sql-stringNo findings on this card: the rule follows params into a SQL string within one file, and here the controller passes the values to OrderQueries in app/queries/. On a one-file probe with the same code in a controller it flagged the interpolated where() of the vulnerable and the wrong code (sanitize_sql_like is not a sanitizer to it), and neither order() call, including the one wrapped in Arel.sql().Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepruby.rails.security.brakeman.check-sql.check-sqlNo findings on the card or on the one-file probe.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); every Ruby rule in semgrep-rules develop 9bbf021 run on app/ and config/, then --config r/<id>
Semgrepp/ruby, p/owasp-top-ten, p/security-auditNo findings on any implementation.Checked: Semgrep 1.180.0 (CE); --config p/ruby, p/owasp-top-ten and p/security-audit on app/ and config/
CodeQLrb/sql-injectionNot expected to report anything on this card as written: the controllers read values with params.expect, which codeql/ruby/frameworks/ActionController.qll does not carry taint through, and OrderQueries.current chooses the module with const_get, which leaves the call unresolved. With params[...] passed to a method called directly, ActiveRecord.qll makes the where() string, every order() argument and Arel.sql() SQL sinks, so the vulnerable code and the wrong order() would be flagged; sanitize_sql_like has no model (ActiveRecord.qll lists Active Record sanitizers as a TODO), so taint stops there and the wrong where() would likely be missed. Hash conditions are not sinks.Checked: github/codeql main at b5b8165; query and Ruby library source read, not executed
BrakemanSQLSQL injection4 warnings, all in app/queries/: the two interpolated where() strings and the Arel.sql() order in vulnerable.rb (Medium confidence) and the where() with sanitize_sql_like in wrong.rb (High). None in fixed.rb. The wrong order("#{sort} #{direction}") is not reported, here or on a one-file probe: without Arel.sql(), Brakeman relies on Active Record's raw-SQL check, which still accepts any column name.Checked: Brakeman 8.1.0 in the ruby:4.0.7 image; brakeman -A -q /card (all checks, optional ones included)

Libraries and versions

Libraries and versions
ComponentVersion
LanguageRuby
RuntimeRuby 4.0.7
FrameworkRuby on Rails 8.1.4 (Active Record over SQLite)
rails (railties, actionpack, activerecord)8.1.4
sqlite32.9.6 (bundles SQLite 3.53.2)
minitest6.0.6
minitest-reporters (JUnitReporter)1.8.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the sql-injection/ruby-railsfolder.

Shell
BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install
Shell
# Expected: every test passes
IMPL=fixed BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/orders_test.rb

# Expected: an exploit test fails
IMPL=vulnerable BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/orders_test.rb

# Expected: a test fails
IMPL=wrong BUNDLE_PATH=vendor/bundle bundle exec ruby -Itest test/orders_test.rb

Without Ruby installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.

Shell
docker run --rm --init \
  -v "$PWD":/card -w /card \
  -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'BUNDLE_PATH=vendor/bundle BUNDLE_FROZEN=true bundle install'
Shell
# Expected: every test passes
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=fixed -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/orders_test.rb'

# Expected: an exploit test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=vulnerable -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/orders_test.rb'

# Expected: a test fails
docker run --rm --init --network=none \
  -v "$PWD":/card -w /card \
  -e IMPL=wrong -e BUNDLE_PATH=vendor/bundle \
  ruby:4.0.7-trixie@sha256:0192d5244fa068587325c3ef4f886fdb2d6775689757d598f9a5261d0f86d40e \
  sh -c 'bundle exec ruby -Itest test/orders_test.rb'

Each run writes a JUnit report to reports/TEST-*.xml. From the top folder of the download, node run-all.mjs sql-injection/ruby-rails runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.

Download the project ZIP, 26 KB

Verified

Machine
Debian 13 (trixie) in Docker, on macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • ruby 4.0.7 (ruby:4.0.7-trixie image, linux/arm64)
  • bundler 4.0.20
  • rails 8.1.4
  • minitest 6.0.6
  • minitest-reporters 1.8.0
  • sqlite3 2.9.6 (SQLite 3.53.2)
  • semgrep 1.180.0
  • brakeman 8.1.0
Matrix
fixed: 12 of 12 tests pass; vulnerable: 8 of 12 tests fail (6 exploit tests); wrong: 6 of 12 tests fail (4 exploit tests)
Test results
fixed 12/12 pass; vulnerable 8/12 fail (6 exploit tests: all six, plus O'Brien and the unknown sort column as 500s); wrong 6/12 fail (4 exploit tests: the tautology, the UNION as a 500 because sanitize_sql_like escapes the underscores in total_cents and created_at, the comment and the hidden-column sort, plus O'Brien and the unknown sort column), test runs offline (--network=none) after install; node examples/fixes/run-all.mjs ruby-rails --install, results/2026-10-11-fix-rails.json
Dependency freshness
Rails 8.1.4 was published on 2026-09-24 and Brakeman 8.1.0 on 2026-10-01. uri 1.1.2 and prettyprint 0.2.1 (both 2026-10-08) were three days old and are not security releases, so the Gemfile holds uri at 1.1.1 and prettyprint at 0.2.0; every other gem in Gemfile.lock is at least 13 days old. OSV reported no known vulnerabilities for any pin. Ruby 4.0.7 (2026-09-15) is the latest 4.0 release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. Rails Guides: Securing Rails Applications, SQL injection
  3. Rails Guides: Active Record Query Interface, pure string and array conditions
  4. Rails API: ActiveRecord::QueryMethods (where, order)
  5. Rails API: ActiveRecord::Sanitization::ClassMethods (sanitize_sql_array, sanitize_sql_like)
  6. Rails SQL Injection Examples (rails-sqli.org, tested on Rails 6.1)
  7. Justin Collins: Rails 6.1 SQL injection updates (order() argument checks)
  8. Brakeman: SQL injection
  9. CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.