2018-02-26

RailsのCSRF周りのコードリーディング

RailsのCSRF周りのコードリーディングをしました。

コードリーディングをする前に使い方を復習すると、protect_from_forgeryメソッドをコントローラで呼んで、csrf_meta_tagsのヘルパーでmetaタグを埋め込めばOKです。

<!DOCTYPE html>
<html>
  <head>
    <title>BasicRails</title>
    <%= csrf_meta_tags %>

ActionController::RequestForgeryProtection::ClassMethods#protect_from_forgeryは以下のようになっています。

module ActionController #:nodoc:
  module RequestForgeryProtection
    module ClassMethods
      def protect_from_forgery(options = {})
        options = options.reverse_merge(prepend: false)

        self.forgery_protection_strategy = protection_method_class(options[:with] || :null_session)
        self.request_forgery_protection_token ||= :authenticity_token
        before_action :verify_authenticity_token, options
        append_after_action :verify_same_origin_request
      end
# ...
    end

    private
      def form_authenticity_token(form_options: {})
        masked_authenticity_token(session, form_options: form_options)
      end

verify_authenticity_tokenはCSRFトークンを検証するbefore_actionのメソッド、クラス変数を操作しているのはCSRFのストラテジやトークンのパラメータ名になります。

ビューから呼び出せるcsrf_meta_tagsメソッドはActionView::Helpers::CsrfHelperのメソッドです。

module ActionView
  module Helpers
    module CsrfHelper
      def csrf_meta_tags
        if protect_against_forgery?
          [
            tag("meta", name: "csrf-param", content: request_forgery_protection_token),
            tag("meta", name: "csrf-token", content: form_authenticity_token)
          ].join("\n").html_safe
        end
      end

#request_forgery_protection_tokenは:authenticity_tokenがセットされます。

#form_authenticity_tokenは#masked_authenticity_tokenを呼び出します。

      def masked_authenticity_token(session, form_options: {}) # :doc:
        action, method = form_options.values_at(:action, :method)

        raw_token = if per_form_csrf_tokens && action && method
          action_path = normalize_action_path(action)
          per_form_csrf_token(session, action_path, method)
        else
          real_csrf_token(session)
        end

        one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH)
        encrypted_csrf_token = xor_byte_strings(one_time_pad, raw_token)
        masked_token = one_time_pad + encrypted_csrf_token
        Base64.strict_encode64(masked_token)
      end

      def real_csrf_token(session) # :doc:
        session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH)
        Base64.strict_decode64(session[:_csrf_token])
      end

real_csrf_tokenはSecureRandomを使ってランダムなトークンを生成してセッションに格納します。one_time_padを生成して、csrf_tokenに対してXORを取って暗号化し、暗号化したトークンとone_time_padを連結したものをbase64エンコードしたものを返します。実体のsession['_csrf_token']はセッションごとに生成しつつ、ユーザに対して発行されるCSRFトークンはリクエスト毎に変わってくるため、SSLのBREACH攻撃を防ぐことができます。

CSRFトークン検証用の#verify_authenticity_tokenは#valid_authenticity_token?を呼び出します。上記で行ったマスキングの逆の操作を行い、CSRFトークンの検証を行います。

      def valid_authenticity_token?(session, encoded_masked_token) # :doc:
        if encoded_masked_token.nil? || encoded_masked_token.empty? || !encoded_masked_token.is_a?(String)
          return false
        end

        begin
          masked_token = Base64.strict_decode64(encoded_masked_token)
        rescue ArgumentError # encoded_masked_token is invalid Base64
          return false
        end

        # See if it's actually a masked token or not. In order to
        # deploy this code, we should be able to handle any unmasked
        # tokens that we've issued without error.

        if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
          # This is actually an unmasked token. This is expected if
          # you have just upgraded to masked tokens, but should stop
          # happening shortly after installing this gem.
          compare_with_real_token masked_token, session

        elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
          csrf_token = unmask_token(masked_token)

          compare_with_real_token(csrf_token, session) ||
            valid_per_form_csrf_token?(csrf_token, session)
        else
          false # Token is malformed.
        end
      end

verifyに失敗した場合は#handle_unverified_request経由でforgery_protection_strategy.new(self).handle_unverified_request が呼ばれます。#protect_from_forgeryのwithオプションに:exceptionを指定した場合は、forgery_protection_strategyはActionController::RequestForgeryProtection::ProtectionMethods::Exceptionになります。この場合、#handle_unverified_requestの処理はActionController::InvalidAuthenticityTokenのraiseになります。

module ActionController #:nodoc:
  module RequestForgeryProtection
    module ProtectionMethods
      class Exception
        def initialize(controller)
          @controller = controller
        end

        def handle_unverified_request
          raise ActionController::InvalidAuthenticityToken
        end
      end
    end
このエントリーをはてなブックマークに追加