ActiveRecordのsanitizeについてのまとめ1

ActiveRecordについて調べる必要があったのでまとめました。
調べたソースコードactiverecord-2.3.5 です。
どっか間違えていたら教えてもらえると助かります。><

ActiveRecord::Base#findから

ActiveRecordから派生したクラスを使い、下記のようなコードで呼び出す。

Person.find(:all, :conditions => [ "category IN (?)", categories], :limit => 50)
Person.find(:all, :conditions => { :friends => ["Bob", "Steve", "Fred"] }

findのコードはどうなっているかというと、

      def find(*args)
        options = args.extract_options!
        validate_find_options(options)
        set_readonly_option!(options)

        case args.first
          when :first then find_initial(options)
          when :last  then find_last(options)
          when :all   then find_every(options)
          else             find_from_ids(args, options)
        end
      end

optionsに上記で渡した、conditionsやlimitが入ります。

最初の引数により、find_initial, find_last, find_everyを呼び出すようになっている。
想定したシンボルじゃないものが渡された時は、IDが渡されたと判断して、find_from_ids を呼びます。

ActiveRecord::Base#find_initial/find_last

find -> find_initial

find_initial/find_lastは最後にfind_everyを呼び出しているので、先に説明します。

        def find_initial(options)
          options.update(:limit => 1)
          find_every(options).first
        end

find_initialはlimit=1を設定して、find_everyを呼び出しています。

        def find_last(options)
          order = options[:order]

          if order
            order = reverse_sql_order(order)
          elsif !scoped?(:find, :order)
            order = "#{table_name}.#{primary_key} DESC"
          end

          if scoped?(:find, :order)
            scope = scope(:find)
            original_scoped_order = scope[:order]
            scope[:order] = reverse_sql_order(original_scoped_order)
          end

          begin
            find_initial(options.merge({ :order => order }))
          ensure
            scope[:order] = original_scoped_order if original_scoped_order
          end
        end

find_lastはorderが指定されていればそのorderの項目、なければprimary keyを逆順にしてfind_initialを呼んでいます。

ActiveRecord::Base#find_every

find -> find_initial -> find_every

        def find_every(options)
          include_associations = merge_includes(scope(:find, :include), options[:include])

          if include_associations.any? && references_eager_loaded_tables?(options)
            records = find_with_associations(options)
          else
            records = find_by_sql(construct_finder_sql(options))
            if include_associations.any?
              preload_associations(records, include_associations)
            end
          end

          records.each { |record| record.readonly! } if options[:readonly]

          records
        end

find_everyはassociationを使ってJOINしたクエリを投げてrecordsを取得したり(find_with_associations)、
option(conditionやlimitなど)を渡して、recordsを取得します。(find_by_sql)

ActiveRecord::Base#construct_finder_sql

find -> find_initial -> find_every -> construct_finder_sql

find_by_sqlに渡す前にconstruct_finder_sqlを使って、optionsからクエリを構築します。

        def construct_finder_sql(options)
          scope = scope(:find)
          sql  = "SELECT #{options[:select] || (scope && scope[:select]) || default_select(options[:joins] || (scope && scope[:joins]))} "
          sql << "FROM #{options[:from]  || (scope && scope[:from]) || quoted_table_name} "

          add_joins!(sql, options[:joins], scope)
          add_conditions!(sql, options[:conditions], scope)
          add_group!(sql, options[:group], options[:having], scope)
          add_order!(sql, options[:order], scope)
          add_limit!(sql, options, scope)
          add_lock!(sql, options, scope)

          sql
        end

SELECT #{options[:select] ||.... の部分は、

  • findメソッドを呼んだ時の:selectオプションで指定したものがある場合、それをカラム名として使用する
  • scope[:select]は使っていないのかな?(あとで調べる)
  • どちらもなければ、default_selectを通じて、joinしたテーブルがあれば、quoted_table_name.* / なければ * を返す

FROM #{options[:from] || ...の部分は、

  • options[:from] findのoptionsに:fromがあれば、それをテーブル名として使用する
  • scope[:from]は使っていないのかな?(あとで調べる)
  • どちらもなければ、quoted_table_nameをを返す

さて、ココでquoted_table_nameって何?と思ったのでソースを見てみます。

find -> find_initial -> find_every -> construct_finder_sql -> quoted_table_name

      def self.quoted_table_name
        self.connection.quote_table_name(self.table_name)
      end

connectionのquote_table_nameに自分のtable_nameを渡して、quoteしているようですね。
connectionは接続先によって変わるので、覚えておいて後で詳しく見てみましょう。

add_joins!はSQL Injectionにあまり関係がなさそうなので、スルー。

SQL Injectionの場合に注意したいのが、add_conditions!です。

よく、select * from table_name where xxx = '[value]' の [value] を [' and 1 = 1--]とかにされて、
select * from table_name where xxx= '' and 1 = 1--' というクエリを投げてしまうコードを書いてしまうことがあります。

ActiveRecord::Base#add_conditions!

find -> find_initial -> find_every -> construct_finder_sql -> add_conditions!

        def add_conditions!(sql, conditions, scope = :auto)
          scope = scope(:find) if :auto == scope
          conditions = [conditions]
          conditions << scope[:conditions] if scope
          conditions << type_condition if finder_needs_type_condition?
          merged_conditions = merge_conditions(*conditions)
          sql << "WHERE #{merged_conditions} " unless merged_conditions.blank?
        end

気がついたけど、scopeはnamed_scopeのことかな?(後で聞いてみる)

  • finder_needs_type_conditions?とtype_condition

ActiveRecord::Baseを派生した時に使用するため、割愛。*1

ActiveRecord::Base#merge_conditions

find() -> .... -> construct_finder_sql -> add_conditions! -> merge_conditions

      def merge_conditions(*conditions)
        segments = []

        conditions.each do |condition|
          unless condition.blank?
            sql = sanitize_sql(condition)
            segments << sql unless sql.blank?
          end
        end

        "(#{segments.join(') AND (')})" unless segments.empty?
      end
  • conditionsのArrayをそれぞれの項目に対して、sanitize_sqlをする
  • 結果を (xxx) AND (yyy) のような文字列にして返す。

ふー、次はようやくsanitize_sqlだ。

ActiveRecord::Base#sanitize_sql

find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql

        alias_method :sanitize_sql, :sanitize_sql_for_conditions

お、よく見ると、sanitize_sqlはsanitize_sql_for_conditionsのエイリアスじゃないか。
なので、sanitize_sql_for_conditionsを見よう。

ActiveRecord::Base#sanitize_sql_for_conditions

find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql_for_conditions

        def sanitize_sql_for_conditions(condition, table_name = quoted_table_name)
          return nil if condition.blank?

          case condition
            when Array; sanitize_sql_array(condition)
            when Hash;  sanitize_sql_hash_for_conditions(condition, table_name)
            else        condition
          end
        end

なるほど、conditionがArrayの時は、sanitize_sql_array, Hashの時はsanitize_sql_hash_for_conditionsを呼ぶのか。

ActiveRecord::Base#sanitize_sql_array

find() -> .... -> merge_conditions -> sanitize_sql_for_conditions -> sanitize_sql_array

        def sanitize_sql_array(ary)
          statement, *values = ary
          if values.first.is_a?(Hash) and statement =~ /:\w+/
            replace_named_bind_variables(statement, values.first)
          elsif statement.include?('?')
            replace_bind_variables(statement, values)
          else
            statement % values.collect { |value| connection.quote_string(value.to_s) }
          end
        end

引数のary(先程のconditions)の一番最初の項目の型により処理をわけてる。

  • Hashなら、replace_named_bind_variables
  • statement(文字列)の中に '?' が入っていたら、replace_bind_variables
  • 上記2つに当てはまらなければ、String.% を使って、フォーマットを指定して文字列を埋め込む
ActiveRecord::Base#replace_named_bind_variables

find() -> .... -> sanitize_sql_for_conditions -> sanitize_sql_array -> replace_named_bind_variables

        def replace_named_bind_variables(statement, bind_vars) #:nodoc:
          statement.gsub(/(:?):([a-zA-Z]\w*)/) do
            if $1 == ':' # skip postgresql casts
              $& # return the whole match
            elsif bind_vars.include?(match = $2.to_sym)
              quote_bound_value(bind_vars[match])
            else
              raise PreparedStatementInvalid, "missing value for :#{match} in #{statement}"
            end
          end
        end

ようやく、終わりに近づいてきたぞ。
statement に :xxx というモノが有れば、bind_vars[:xxx]を見つけてquote_bound_value()に渡す。

ActiveRecord::Base#quote_bound_value

find() -> .... -> sanitize_sql_array -> replace_named_bind_variables -> quote_bind_value

        def quote_bound_value(value) #:nodoc:
          if value.respond_to?(:map) && !value.acts_like?(:string)
            if value.respond_to?(:empty?) && value.empty?
              connection.quote(nil)
            else
              value.map { |v| connection.quote(v) }.join(',')
            end
          else
            connection.quote(value)
          end
        end

valueにmapメソッドがあれば、1,2,'string',4のような形式にする
そうでなければ、quoteした値を返す。

connection.quote()は接続するDBによって違うのですが、デフォルトの定義を見てみましょう。
このデフォルトの定義は、各connection側で再定義し直せば上書きすることが可能です。

ActiveRecord::ConnectionAdapters::AbstractAdapter#quote

find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote

    class AbstractAdapter
      include Quoting, DatabaseStatements, SchemaStatements
      include QueryCache
      include ActiveSupport::Callbacks
    .....

クラスの定義をみるとQuotingをincludeしているので、Quotingの定義を見てみます。

ActiveRecord::ConnectionAdapters::Quoting

find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote(Quoting included)
ちょっと長いけど、先に説明するのでソースを眺めてみてください。

quoteで渡されたvalueの型により場合分け

  1. String,ActiveSupport::Multibyte::Charsの場合
    • カラムのタイプが、:binaryだったときは、string_to_binaryを使って、バイナリにしたものをquoteする(これはあんまり使わないかも)
    • DBのカラムのタイプがinteger,floatの場合(渡されたRubyのClassはString)、to_i, to_f で数値に変更する
    • それ以外(普通の文字列)の場合は、quote_stringを使って、quoteする
      • teststring -> 'teststring',
      • single quote' string -> 'single quote'' string'
      • teststring -> N'teststring' とかもあるかな?
  2. NilClassの場合
    • NULL を返す
  3. TrueClass,FalseClassの場合
    • t,f or 1,0 を返す
  4. Integer,Floatの場合
    • value.to_i,value.to_fを使って一度数値に変えて、to_sで文字列にしたものを返す。(''で囲わない) 1 -> 1, 1.5223 -> 1.5223
  5. BigDecimalの場合
    • to_sの結果を返すが、quoteする必要があるかもよ。
  6. それ以外の場合(どんなときだ? 定義したclassとか?)
    • date,timeとして扱えるときは、 '2010-05-12 00:00:00' とかに変換する
    • 扱えないときは quote_stringを使って、quoteする。
    module Quoting
      # Quotes the column value to help prevent
      # {SQL injection attacks}[http://en.wikipedia.org/wiki/SQL_injection].
      def quote(value, column = nil)
        # records are quoted as their primary key
        return value.quoted_id if value.respond_to?(:quoted_id)

        case value
          when String, ActiveSupport::Multibyte::Chars
            value = value.to_s
            if column && column.type == :binary && column.class.respond_to?(:string_to_binary)
              "#{quoted_string_prefix}'#{quote_string(column.class.string_to_binary(value))}'" # ' (for ruby-mode)
            elsif column && [:integer, :float].include?(column.type)
              value = column.type == :integer ? value.to_i : value.to_f
              value.to_s
            else
              "#{quoted_string_prefix}'#{quote_string(value)}'" # ' (for ruby-mode)
            end
          when NilClass                 then "NULL"
          when TrueClass                then (column && column.type == :integer ? '1' : quoted_true)
          when FalseClass               then (column && column.type == :integer ? '0' : quoted_false)
          when Float, Fixnum, Bignum    then value.to_s
          # BigDecimals need to be output in a non-normalized form and quoted.
          when BigDecimal               then value.to_s('F')
          else
            if value.acts_like?(:date) || value.acts_like?(:time)
              "'#{quoted_date(value)}'"
            else
              "#{quoted_string_prefix}'#{quote_string(value.to_yaml)}'"
            end
        end
      end

      # Quotes a string, escaping any ' (single quote) and \ (backslash)
      # characters.
      def quote_string(s)
        s.gsub(/\\/, '\&\&').gsub(/'/, "''") # ' (for ruby-mode)
      end

      # Quotes the column name. Defaults to no quoting.
      def quote_column_name(column_name)
        column_name
      end

      # Quotes the table name. Defaults to column name quoting.
      def quote_table_name(table_name)
        quote_column_name(table_name)
      end

      def quoted_true
        "'t'"
      end

      def quoted_false
        "'f'"
      end

      def quoted_date(value)
        value.to_s(:db)
      end

      def quoted_string_prefix
        ''
      end
    end

quoteの詳細もわかったところで、ActiveRecord::Base#sanitize_sql_arrayに一度戻って、次のreplace_bind_variablesを調べよう。

ActiveRecord::Base#sanitize_sql_array

find() -> .... -> replace_named_bind_variables -> quote_bind_value -> connection.quote(Quoting included)
こんなコードだった。

          elsif statement.include?('?')
            replace_bind_variables(statement, values)

どうやら、:conditions=>["column_name = ?", 'name'] みたいな時に使われるようだ。

ActiveRecord::Base#replace_bind_variables

replace_bind_variablesの実装は

        def replace_bind_variables(statement, values) #:nodoc:
          raise_if_bind_arity_mismatch(statement, statement.count('?'), values.size)
          bound = values.dup
          statement.gsub('?') { quote_bound_value(bound.shift) }
        end

引数の数を調べたあとに、statement.gsub で ? を valuesの値で置き換える
quote_bound_value はさっきみたので、華麗にスルーします。


さて、また戻ります。

今まで追ってきたところは、ActiveRecord::sanitize_sql_for_conditionsなので、もう一度ソースを見てみよう。

ActiveRecord::Base#sanitize_sql_for_conditions

find() -> .... -> add_conditions! -> merge_conditions -> sanitize_sql_for_conditions

        def sanitize_sql_for_conditions(condition, table_name = quoted_table_name)
          return nil if condition.blank?

          case condition
            when Array; sanitize_sql_array(condition)
            when Hash;  sanitize_sql_hash_for_conditions(condition, table_name)
            else        condition
          end
        end

ああ、思い出した。sanitize_sql_array()を深く追っていたんだ。
次は、sanitize_sql_hash_for_conditionsを追ってみます。

ActiveRecord::Base#sanitize_sql_hash_for_conditions

find() -> .... -> merge_conditions -> sanitize_sql_for_conditions -> sanitize_sql_hash_for_conditions

        def sanitize_sql_hash_for_conditions(attrs, default_table_name = quoted_table_name)
          attrs = expand_hash_conditions_for_aggregates(attrs)

          conditions = attrs.map do |attr, value|
            table_name = default_table_name

            unless value.is_a?(Hash)
              attr = attr.to_s

              # Extract table name from qualified attribute names.
              if attr.include?('.')
                attr_table_name, attr = attr.split('.', 2)
                attr_table_name = connection.quote_table_name(attr_table_name)
              else
                attr_table_name = table_name
              end

              attribute_condition("#{attr_table_name}.#{connection.quote_column_name(attr)}", value)
            else
              sanitize_sql_hash_for_conditions(value, connection.quote_table_name(attr.to_s))
            end
          end.join(' AND ')

          replace_bind_variables(conditions, expand_range_bind_variables(attrs.values))
        end

expand_hash_conditions_for_aggregatesは、conditionsで指定したhashのキーから、DBのカラムへマッピングしたものを返します。
マッピング自体は、composed_ofで指定します。
例文としては

  class Customer < ActiveRecord::Base
    composed_of :balance, :class_name => "Money", :mapping => %w(balance amount)
  end

という定義があったら、Customer.find(:all,conditions=>[:balance,'1000'] とかできるってことかな。(あんまり自信がない。間違えてたら指摘してください。)


処理している内容は

  • conditionsのvalueがHashなら、attribute_conditionを呼び出します。
  • そうでなければ、自分自身(sanitize_sql_hash_for_conditions)を呼び出して、再帰的に処理します。
  • 最後にそれぞれの値を' AND 'を使ってJOINします

attribute_conditionのコードも見てみます。

AtiveRecord::Base#attribute_condition

find() -> .... sanitize_sql_for_conditions -> sanitize_sql_hash_for_conditions -> attribute_condition

        def attribute_condition(quoted_column_name, argument)
          case argument
            when nil   then "#{quoted_column_name} IS ?"
            when Array, ActiveRecord::Associations::AssociationCollection, ActiveRecord::NamedScope::Scope then "#{quoted_column_name} IN (?)"
            when Range then if argument.exclude_end?
                              "#{quoted_column_name} >= ? AND #{quoted_column_name} < ?"
                            else
                              "#{quoted_column_name} BETWEEN ? AND ?"
                            end
            else            "#{quoted_column_name} = ?"
          end
        end

quoted_column_nameには、conditionsに渡したキーの名前(カラム名)、argumentにはキーに対応した値が入ってきます。

  • argumentがnilの場合には、"column_name IS ?"
  • Arrayの場合には、"column_name IN (?)"
  • Rangeの場合には、"column_name >= ? AND column_name < ?" や "column_name BETWEEN ? AND ?" や "column_name = ?"

を返します。


結果として、replace_bind_variables() を呼ぶ前まで conditions は、"column_name1 = ? AND column_name2 IN (?)"というような文字列になります。

replace_bind_variables()は先程見たように、? をgsub()してquoteされたvalueに置き換えます。
なので、{:column_name1 => 1, :column_name2 => [1,'2']} が "column_name = 1 AND column_name IN (1,'2')" という文字列になります。

今まで、add_conditions!を追ってきたので、もう一度戻ります。

ActiveRecord::Base#construct_finder_sql

find -> find_initial -> find_every -> construct_finder_sql

        def construct_finder_sql(options)
          scope = scope(:find)
          sql  = "SELECT #{options[:select] || (scope && scope[:select]) || default_select(options[:joins] || (scope && scope[:joins]))} "
          sql << "FROM #{options[:from]  || (scope && scope[:from]) || quoted_table_name} "

          add_joins!(sql, options[:joins], scope)
          add_conditions!(sql, options[:conditions], scope)
          add_group!(sql, options[:group], options[:having], scope)
          add_order!(sql, options[:order], scope)
          add_limit!(sql, options, scope)
          add_lock!(sql, options, scope)

          sql
        end

あとは、add_group!, add_order!, add_limit!, add_lock! を呼んでいます。
上から順にソースを見ていきます。

ActiveRecord::Base#add_group!

find -> find_initial -> find_every -> construct_finder_sql -> add_group!

        def add_group!(sql, group, having, scope = :auto)
          if group
            sql << " GROUP BY #{group}"
            sql << " HAVING #{sanitize_sql_for_conditions(having)}" if having
          else
            scope = scope(:find) if :auto == scope
            if scope && (scoped_group = scope[:group])
              sql << " GROUP BY #{scoped_group}"
              sql << " HAVING #{sanitize_sql_for_conditions(scope[:having])}" if scope[:having]
            end
          end
        end

findで渡した、:group,:havingを使って、sanitize_sql_for_conditionsを呼んでいます。
sanitize_sql_for_conditionsはhashなどを受け取って、サニタイズされたSQLを作ります。
結果として、サニタイズされた having句を作ります。

ActiveRecord::Base#add_order!

find -> find_initial -> find_every -> construct_finder_sql -> add_order!

        def add_order!(sql, order, scope = :auto)
          scope = scope(:find) if :auto == scope
          scoped_order = scope[:order] if scope
          if order
            sql << " ORDER BY #{order}"
            if scoped_order && scoped_order != order
              sql << ", #{scoped_order}"
            end
          else
            sql << " ORDER BY #{scoped_order}" if scoped_order
          end
        end

特筆することはないですね。そのまま:orderに渡されたものを、order句に書くだけです。

ActiveRecord::Base#add_limit!

find -> find_initial -> find_every -> construct_finder_sql -> add_limit!

        def add_limit!(sql, options, scope = :auto)
          scope = scope(:find) if :auto == scope

          if scope
            options[:limit] ||= scope[:limit]
            options[:offset] ||= scope[:offset]
          end

          connection.add_limit_offset!(sql, options)
        end

connection.add_limit_offset!を呼んでいます。
この定義はどこにあるかというと、AbstractAdapterでincludeされているDatabaseStatementsに有ります。

    class AbstractAdapter
      include Quoting, DatabaseStatements, SchemaStatements
ActiveRecord::ConnectionAdapters::DatabaseStatements#add_limit_offset!

find -> ... -> construct_finder_sql -> connection.add_limit! -> add_limit_offset!

      def add_limit_offset!(sql, options)
        if limit = options[:limit]
          sql << " LIMIT #{sanitize_limit(limit)}"
          if offset = options[:offset]
            sql << " OFFSET #{offset.to_i}"
          end
        end
        sql
      end

末尾に LIMIT n,m や OFFSET n を追加しています。

一応、sanitize_limitの中も確認しておきます。

ActiveRecord::ConnectionAdapters::DatabaseStatements#sanitize_limit

find -> ... -> connection.add_limit! -> add_limit_offset -> sanitize_limit

        def sanitize_limit(limit)
          if limit.to_s =~ /,/
            limit.to_s.split(',').map{ |i| i.to_i }.join(',')
          else
            limit.to_i
          end
        end

to_iで数値に変換しているだけですね。

ActiveRecord::Base#add_lock!

find -> find_initial -> find_every -> construct_finder_sql -> add_lock!

      def add_lock!(sql, options)
        case lock = options[:lock]
          when true;   sql << ' FOR UPDATE'
          when String; sql << " #{lock}"
        end
      end

options[:lock] の値によって、FOR UPDTEか渡された文字列をクエリの末尾に付け加えてます。

ActiveRecord::Base#add_lock!

長かったconstruct_finder_sqlも終わりです。
最後に作成したクエリを返しています。

find -> find_initial -> find_every -> construct_finder_sql

        def construct_finder_sql(options)
          .....
          add_lock!(sql, options, scope)

          sql
        end
ActiveRecord::Base#find_every

コードを忘れてしまったので、もう一度載せます。

find -> find_initial -> find_every

        def find_every(options)
          include_associations = merge_includes(scope(:find, :include), options[:include])

          if include_associations.any? && references_eager_loaded_tables?(options)
            records = find_with_associations(options)
          else
            records = find_by_sql(construct_finder_sql(options))
            if include_associations.any?
              preload_associations(records, include_associations)
            end
          end

          records.each { |record| record.readonly! } if options[:readonly]

          records
        end

construct_finder_sqlで作成したクエリをfind_by_sqlに渡しています。

ActiveRecord::Base#find_by_sql
      def find_by_sql(sql)
        connection.select_all(sanitize_sql(sql), "#{name} Load").collect! { |record| instantiate(record) }
      end

sanitize_sql呼んでいるけど、意味あるんかな? Arrayでもないし、Hashでもないので、そのまま文字列を返すだけ。

とりあえず、connection.select_all() に渡す時点でsanitizeされているのは確認できました。

長くなってしまったので、create/saveなどは次の記事で書く予定。

*1:STIとかApplicationModelとかでぐぐるとよさげ