Skip to content

Commit

Permalink
Merge pull request #36420 from kamipo/quoted_identifier_regex
Browse files Browse the repository at this point in the history
Allow quoted identifier string as safe SQL string
  • Loading branch information
kamipo committed Jun 6, 2019
1 parent 60a6bce commit c168fa0
Show file tree
Hide file tree
Showing 10 changed files with 166 additions and 68 deletions.
53 changes: 0 additions & 53 deletions activerecord/lib/active_record/attribute_methods.rb
Expand Up @@ -159,59 +159,6 @@ def attribute_names
end
end

# Regexp for column names (with or without a table name prefix). Matches
# the following:
# "#{table_name}.#{column_name}"
# "#{column_name}"
COLUMN_NAME = /\A(?:\w+\.)?\w+\z/i

# Regexp for column names with order (with or without a table name
# prefix, with or without various order modifiers). Matches the following:
# "#{table_name}.#{column_name}"
# "#{table_name}.#{column_name} #{direction}"
# "#{table_name}.#{column_name} #{direction} NULLS FIRST"
# "#{table_name}.#{column_name} NULLS LAST"
# "#{column_name}"
# "#{column_name} #{direction}"
# "#{column_name} #{direction} NULLS FIRST"
# "#{column_name} NULLS LAST"
COLUMN_NAME_WITH_ORDER = /
\A
(?:\w+\.)?
\w+
(?:\s+asc|\s+desc)?
(?:\s+nulls\s+(?:first|last))?
\z
/ix

def disallow_raw_sql!(args, permit: COLUMN_NAME) # :nodoc:
unexpected = nil
args.each do |arg|
next if arg.is_a?(Symbol) || Arel.arel_node?(arg) ||
arg.to_s.split(/\s*,\s*/).all? { |part| permit.match?(part) }
(unexpected ||= []) << arg
end

return unless unexpected

if allow_unsafe_raw_sql == :deprecated
ActiveSupport::Deprecation.warn(
"Dangerous query method (method whose arguments are used as raw " \
"SQL) called with non-attribute argument(s): " \
"#{unexpected.map(&:inspect).join(", ")}. Non-attribute " \
"arguments will be disallowed in Rails 6.1. This method should " \
"not be called with user-provided values, such as request " \
"parameters or model attributes. Known-safe values can be passed " \
"by wrapping them in Arel.sql()."
)
else
raise(ActiveRecord::UnknownAttributeReference,
"Query method called with non-attribute argument(s): " +
unexpected.map(&:inspect).join(", ")
)
end
end

# Returns true if the given attribute exists, otherwise false.
#
# class Person < ActiveRecord::Base
Expand Down
Expand Up @@ -142,6 +142,43 @@ def sanitize_as_sql_comment(value) # :nodoc:
value.to_s.gsub(%r{ (/ (?: | \g<1>) \*) \+? \s* | \s* (\* (?: | \g<2>) /) }x, "")
end

def column_name_matcher # :nodoc:
COLUMN_NAME
end

def column_name_with_order_matcher # :nodoc:
COLUMN_NAME_WITH_ORDER
end

# Regexp for column names (with or without a table name prefix).
# Matches the following:
#
# "#{table_name}.#{column_name}"
# "#{column_name}"
COLUMN_NAME = /\A(?:\w+\.)?\w+\z/i

# Regexp for column names with order (with or without a table name prefix,
# with or without various order modifiers). Matches the following:
#
# "#{table_name}.#{column_name}"
# "#{table_name}.#{column_name} #{direction}"
# "#{table_name}.#{column_name} #{direction} NULLS FIRST"
# "#{table_name}.#{column_name} NULLS LAST"
# "#{column_name}"
# "#{column_name} #{direction}"
# "#{column_name} #{direction} NULLS FIRST"
# "#{column_name} NULLS LAST"
COLUMN_NAME_WITH_ORDER = /
\A
(?:\w+\.)?
\w+
(?:\s+ASC|\s+DESC)?
(?:\s+NULLS\s+(?:FIRST|LAST))?
\z
/ix

private_constant :COLUMN_NAME, :COLUMN_NAME_WITH_ORDER

private
def type_casted_binds(binds)
if binds.first.is_a?(Array)
Expand Down
Expand Up @@ -32,12 +32,33 @@ def quoted_binary(value)
"x'#{value.hex}'"
end

def _type_cast(value)
case value
when Date, Time then value
else super
end
def column_name_matcher
COLUMN_NAME
end

def column_name_with_order_matcher
COLUMN_NAME_WITH_ORDER
end

COLUMN_NAME = /\A(?:(`?)\w+\k<1>\.)?(`?)\w+\k<2>\z/i

COLUMN_NAME_WITH_ORDER = /
\A
(?:(`?)\w+\k<1>\.)?
(`?)\w+\k<2>
(?:\s+ASC|\s+DESC)?
\z
/ix

private_constant :COLUMN_NAME, :COLUMN_NAME_WITH_ORDER

private
def _type_cast(value)
case value
when Date, Time then value
else super
end
end
end
end
end
Expand Down
Expand Up @@ -78,6 +78,28 @@ def lookup_cast_type_from_column(column) # :nodoc:
type_map.lookup(column.oid, column.fmod, column.sql_type)
end

def column_name_matcher
COLUMN_NAME
end

def column_name_with_order_matcher
COLUMN_NAME_WITH_ORDER
end

COLUMN_NAME = /\A(?:("?)\w+\k<1>\.)?("?)\w+\k<2>(?:::\w+)?\z/i

COLUMN_NAME_WITH_ORDER = /
\A
(?:("?)\w+\k<1>\.)?
("?)\w+\k<2>
(?:::\w+)?
(?:\s+ASC|\s+DESC)?
(?:\s+NULLS\s+(?:FIRST|LAST))?
\z
/ix

private_constant :COLUMN_NAME, :COLUMN_NAME_WITH_ORDER

private
def lookup_cast_type(sql_type)
super(query_value("SELECT #{quote(sql_type)}::regtype::oid", "SCHEMA").to_i)
Expand Down
Expand Up @@ -45,6 +45,26 @@ def unquoted_false
0
end

def column_name_matcher
COLUMN_NAME
end

def column_name_with_order_matcher
COLUMN_NAME_WITH_ORDER
end

COLUMN_NAME = /\A(?:("?)\w+\k<1>\.)?("?)\w+\k<2>\z/i

COLUMN_NAME_WITH_ORDER = /
\A
(?:("?)\w+\k<1>\.)?
("?)\w+\k<2>
(?:\s+ASC|\s+DESC)?
\z
/ix

private_constant :COLUMN_NAME, :COLUMN_NAME_WITH_ORDER

private

def _type_cast(value)
Expand Down
2 changes: 1 addition & 1 deletion activerecord/lib/active_record/relation/query_methods.rb
Expand Up @@ -1254,7 +1254,7 @@ def preprocess_order_args(order_args)

@klass.disallow_raw_sql!(
order_args.flat_map { |a| a.is_a?(Hash) ? a.keys : a },
permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER
permit: connection.column_name_with_order_matcher
)

validate_order_args(order_args)
Expand Down
33 changes: 31 additions & 2 deletions activerecord/lib/active_record/sanitization.rb
Expand Up @@ -61,8 +61,9 @@ def sanitize_sql_for_assignment(assignments, default_table_name = table_name)
# # => "id ASC"
def sanitize_sql_for_order(condition)
if condition.is_a?(Array) && condition.first.to_s.include?("?")
disallow_raw_sql!([condition.first],
permit: AttributeMethods::ClassMethods::COLUMN_NAME_WITH_ORDER
disallow_raw_sql!(
[condition.first],
permit: connection.column_name_with_order_matcher
)

# Ensure we aren't dealing with a subclass of String that might
Expand Down Expand Up @@ -133,6 +134,34 @@ def sanitize_sql_array(ary)
end
end

def disallow_raw_sql!(args, permit: connection.column_name_matcher) # :nodoc:
unexpected = nil
args.each do |arg|
next if arg.is_a?(Symbol) || Arel.arel_node?(arg) ||
arg.to_s.split(/\s*,\s*/).all? { |part| permit.match?(part) }
(unexpected ||= []) << arg
end

return unless unexpected

if allow_unsafe_raw_sql == :deprecated
ActiveSupport::Deprecation.warn(
"Dangerous query method (method whose arguments are used as raw " \
"SQL) called with non-attribute argument(s): " \
"#{unexpected.map(&:inspect).join(", ")}. Non-attribute " \
"arguments will be disallowed in Rails 6.1. This method should " \
"not be called with user-provided values, such as request " \
"parameters or model attributes. Known-safe values can be passed " \
"by wrapping them in Arel.sql()."
)
else
raise(ActiveRecord::UnknownAttributeReference,
"Query method called with non-attribute argument(s): " +
unexpected.map(&:inspect).join(", ")
)
end
end

private
def replace_bind_variables(statement, values)
raise_if_bind_arity_mismatch(statement, statement.count("?"), values.size)
Expand Down
2 changes: 1 addition & 1 deletion activerecord/test/cases/associations/eager_test.rb
Expand Up @@ -523,7 +523,7 @@ def test_eager_association_loading_with_belongs_to_and_order_string_with_unquote
def test_eager_association_loading_with_belongs_to_and_order_string_with_quoted_table_name
quoted_posts_id = Comment.connection.quote_table_name("posts") + "." + Comment.connection.quote_column_name("id")
assert_nothing_raised do
Comment.includes(:post).references(:posts).order(Arel.sql(quoted_posts_id))
Comment.includes(:post).references(:posts).order(quoted_posts_id)
end
end

Expand Down
4 changes: 2 additions & 2 deletions activerecord/test/cases/relations_test.rb
Expand Up @@ -1679,7 +1679,7 @@ def test_automatically_added_order_references
scope = Post.order("comments.body")
assert_equal ["comments"], scope.references_values

scope = Post.order(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}"))
scope = Post.order("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")
if current_adapter?(:OracleAdapter)
assert_equal ["COMMENTS"], scope.references_values
else
Expand All @@ -1704,7 +1704,7 @@ def test_automatically_added_reorder_references
scope = Post.reorder("comments.body")
assert_equal %w(comments), scope.references_values

scope = Post.reorder(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}"))
scope = Post.reorder("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")
if current_adapter?(:OracleAdapter)
assert_equal ["COMMENTS"], scope.references_values
else
Expand Down
30 changes: 26 additions & 4 deletions activerecord/test/cases/unsafe_raw_sql_test.rb
Expand Up @@ -77,7 +77,7 @@ class UnsafeRawSqlTest < ActiveRecord::TestCase
assert_equal ids_expected, ids_disabled
end

test "order: allows table and column name" do
test "order: allows table and column names" do
ids_expected = Post.order(Arel.sql("title")).pluck(:id)

ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title").pluck(:id) }
Expand All @@ -87,6 +87,17 @@ class UnsafeRawSqlTest < ActiveRecord::TestCase
assert_equal ids_expected, ids_disabled
end

test "order: allows quoted table and column names" do
ids_expected = Post.order(Arel.sql("title")).pluck(:id)

quoted_title = Post.connection.quote_table_name("posts.title")
ids_depr = with_unsafe_raw_sql_deprecated { Post.order(quoted_title).pluck(:id) }
ids_disabled = with_unsafe_raw_sql_disabled { Post.order(quoted_title).pluck(:id) }

assert_equal ids_expected, ids_depr
assert_equal ids_expected, ids_disabled
end

test "order: allows column name and direction in string" do
ids_expected = Post.order(Arel.sql("title desc")).pluck(:id)

Expand Down Expand Up @@ -116,10 +127,10 @@ class UnsafeRawSqlTest < ActiveRecord::TestCase

["asc", "desc", ""].each do |direction|
%w(first last).each do |position|
ids_expected = Post.order(Arel.sql("type #{direction} nulls #{position}")).pluck(:id)
ids_expected = Post.order(Arel.sql("type::text #{direction} nulls #{position}")).pluck(:id)

ids_depr = with_unsafe_raw_sql_deprecated { Post.order("type #{direction} nulls #{position}").pluck(:id) }
ids_disabled = with_unsafe_raw_sql_disabled { Post.order("type #{direction} nulls #{position}").pluck(:id) }
ids_depr = with_unsafe_raw_sql_deprecated { Post.order("type::text #{direction} nulls #{position}").pluck(:id) }
ids_disabled = with_unsafe_raw_sql_disabled { Post.order("type::text #{direction} nulls #{position}").pluck(:id) }

assert_equal ids_expected, ids_depr
assert_equal ids_expected, ids_disabled
Expand Down Expand Up @@ -262,6 +273,17 @@ class UnsafeRawSqlTest < ActiveRecord::TestCase
assert_equal titles_expected, titles_disabled
end

test "pluck: allows quoted table and column names" do
titles_expected = Post.pluck(Arel.sql("title"))

quoted_title = Post.connection.quote_table_name("posts.title")
titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck(quoted_title) }
titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck(quoted_title) }

assert_equal titles_expected, titles_depr
assert_equal titles_expected, titles_disabled
end

test "pluck: disallows invalid column name" do
with_unsafe_raw_sql_disabled do
assert_raises(ActiveRecord::UnknownAttributeReference) do
Expand Down

0 comments on commit c168fa0

Please sign in to comment.