Using bind parameters in Active Record for SQL security and performance


What are bind parameters

Bind parameters are a way to pass values into the SQL statement without embedding the values into the SQL as literals. The values are provided to the SQL statement through parametrization or using a separate API call.

There are two benefits of using bind parameters:

  • Security - Preventing injection attacks by quoting
  • Performance - Improving performance using prepared statements

Security

Bind parameters are the most effective way to fix SQL injection issues. When you embed the user-supplied literals into the query, Active Record cannot perform quoting on it. But when the literals are provided as a parameter, Active Record will quote the literals to prevent injection attacks.

# User supplid sha
short_sha = "e92'1a1c"

# Unsafe
Post.where("sha LIKE '%#{short_sha}%'").to_sql
=> "SELECT \"posts\".* FROM \"posts\" WHERE (sha LIKE '%e92'1a1c%')"

# Safe - Notice the doubling up of the quote character
Post.where("sha LIKE ?", "%#{short_sha}%")
=> "SELECT \"posts\".* FROM \"posts\" WHERE (sha LIKE '%e92''1a1c%')"

Performance

When a parameterized query is executed, ActiveRecord would first prepare the query and later execute it with different parameters. This way the database would be able to reuse the execution plan without the need to parse and optimize the SQL statement each time.

Let’s take an example:

> ActiveRecord::Base.connection.execute("select * from pg_prepared_statements").values
    (1.0ms)  select * from pg_prepared_statements
=> []

# bind params are not used in [where strings](https://github.com/rails/rails/pull/31812)
# they are also not used in [where(foo: [1, 2, 3]](https://github.com/rails/rails/commit/cbcdecd2)
> Commit.where("sha = 'e921a1cc21193902a84016d633a9e10d82555bbd'")
    Commit Load (3.7ms)  SELECT "commits".* FROM "commits" WHERE (sha = 'e921a1cc21193902a84016d633a9e10d82555bbd')
=> ...

# PostgreSQL prepared statement cache is empty
> ActiveRecord::Base.connection.execute("select * from pg_prepared_statements").values
   (0.7ms)  select * from pg_prepared_statements
=> []

> Commit.where(sha: 'e921a1cc21193902a84016d633a9e10d82555bbd')
  Commit Load (1.3ms)  SELECT "commits".* FROM "commits" WHERE "commits"."sha" = $1  [["sha", "e921a1cc21193902a84016d633a9e10d82555bbd"]]
=> ...

# the previous SQL query has been prepared
> ActiveRecord::Base.connection.execute("select * from pg_prepared_statements").values
   (0.9ms)  select * from pg_prepared_statements
=> [["a1", "SELECT \"commits\".* FROM \"commits\" WHERE \"commits\".\"sha\" = $1", 2019-10-17 12:24:35 +0000, "{text}", false]]

Executing custom SQL

When you write a query using find_by_sql with the array syntax, rails will only perform sanitization on the query and not use prepared statements.

> MONTH_WITH_YEAR_SQL = Arel.sql(
    <<-SQL.squish
      to_char(
        date_trunc('month', posts.created_at),
        'FMMonth YYYY'
      )
    SQL
  )

> Post.find_by_sql([
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = :month_and_year",
    {month_and_year: "August 2019"}
  ])
=> ...

> ActiveRecord::Base.connection.execute("select * from pg_prepared_statements").values
  (1.2ms)  select * from pg_prepared_statements
=> []

Now, the question is how to make find_by_sql to use prepared statements. The answer lies in the method signature of find_by_sql.

But before that let’s look at how to pass bind params to find_by_sql.

# passing binds as an array of name, value pairs
> Post.find_by_sql(
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = $1",
    [[nil, "August 2019"]]
  )
Post Load (0.6ms)  select posts.* from posts where to_char( date_trunc('month', posts.created_at), 'FMMonth YYYY' ) = $1  [[nil, "August '2019"]]
=> ...

> binds = [
    ActiveRecord::Relation::QueryAttribute.new(
      nil,                          # name
      "August 2019",                # value
      ActiveRecord::Type::Text.new  # type
    )
  ]

# passing binds as an array of QueryAttribute objects
> Post.find_by_sql(
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = $1",
    binds
  )
Post Load (0.6ms)  select posts.* from posts where to_char( date_trunc('month', posts.created_at), 'FMMonth YYYY' ) = $1  [[nil, "August '2019"]]
=> ...

The third parameter to find_by_sql when true creates the prepared statement in the database.

> Post.find_by_sql(
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = $1",
    [[nil, "August 2019"]],
    preparable: true
  )
=> ...

ActiveRecord::Base.connection.execute("select * from pg_prepared_statements").values
=> [["a1", "select posts.* from posts where to_char( date_trunc('month', posts.created_at), 'FMMonth YYYY' ) = $1", 2019-10-17 14:11:04 +0000, "{text}", false]]

But if we look at the code flow inside ActiveRecord, it will no longer perform sanitization. sanitize_sql is called on the first ( sql ) parameter, which calls sanitize_sql_array when the passed parameter is an Array. Now we need to sanitize the data ourselves before handing it over to find_by_sql.

# Notice the dangling single quote befor the year
> Post.find_by_sql(
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = $1",
    [[nil, "August '2019"]],
    preparable: true
  )
  Post Load (0.6ms)  select posts.* from posts where to_char( date_trunc('month', posts.created_at), 'FMMonth YYYY' ) = $1  [[nil, "August '2019"]]
=> ...

# Notice the single quote has been doubled up
> Post.find_by_sql(
    "select posts.* from posts where #{MONTH_WITH_YEAR_SQL} = $1",
    [[nil, ActiveRecord::Base.connection.quote("August '2019")]],
    preparable: true
  )
  Post Load (0.5ms)  select posts.* from posts where to_char( date_trunc('month', posts.created_at), 'FMMonth YYYY' ) = $1  [[nil, "'August ''2019'"]]
=> ...

Example using exec_query

> ApplicationRecord.connection.exec_query(
    'SELECT * FROM projects WHERE id = $1',
    'SQL',
    [[nil, "30d90923-c51b-4f15-8043-ff1d7eca960b"]],
    prepare: true
  )
SQL (1.0ms)  SELECT * FROM projects WHERE id = $1  [[nil, "30d90923-c51b-4f15-8043-ff1d7eca960b"]]
=> ...

# the second parameter name is what appears in the log
> ApplicationRecord.connection.exec_query(
    'SELECT * FROM projects WHERE id = $1',
    '-- CUSTOM SQL --',
    [[nil, "30d90923-c51b-4f15-8043-ff1d7eca960b"]],
    prepare: true
  )
-- CUSTOM SQL -- (0.6ms)  SELECT * FROM projects WHERE id = $1  [[nil, "30d90923-c51b-4f15-8043-ff1d7eca960b"]]
=> ...