Authenticity_token & csrf_token in Rails 5

Database and Ruby, Python, History


Table of Contents

这边文章是基于深入 Rails 中的 CSRF ProtectionA Deep Dive into CSRF Protection in Rails写的。

Environment

这篇文章是基于Rails 5.2 默认配置,如果是从Rails 4 升级过来的,可能会有出入。

基础知识

Rails的CSRF会被存到3个地方,一个是session cookie中,一个是meta标签中,最后一个是表单的authenticity_token中。当发送POST请求,表单的authenticity_token会被发送到服务器。Rails会验证session中的csrf_token和传过来的authenticity_token。如果是Javascript,则会取meta中的csrf_token,同样发送到后端去验证。深入 Rails 中的 CSRF Protection好像只提到了metacsrf_token的情况,没有提及表单中的authenticity_token这种情况。

csrf-token的生成

application_controller.rb中加入protect_from_forgery即可。

class ApplicationController < ActionController::Base
  protect_from_forgery
end

application.html.erb会有如下的文件

<%= csrf_meta_tags %>

这里就会调用csrf_meta_tags方法。

# actionview-5.2.4.1/lib/action_view/helpers/csrf_helper.rb
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

进而调用form_authenticity_token。在meta标签中,actionmethod都是nil。所以,直接调用real_csrf_token。注意,这里就会把_csrf_token写入session cookie中。后面会有代码去把整个cookie decrypt出来,就可以看到了。

# actionpack-5.2.4.1/lib/action_controller/metal/request_forgery_protection.rb
# Sets the token value for the current session.
def form_authenticity_token(form_options: {})
  masked_authenticity_token(session, form_options: form_options)
end

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

这里生成的raw_token是32位长的。后续就是加密工作了,具体可以参考深入 Rails 中的 CSRF Protection。简单来说,需要再生成一个one_time_pad,然后和raw_token做异或操作,再把one_time_pad拼到前面去,最后再做一个Base64 encode。

Form authenticity_token的生成

和meta标签类似,在生成form的时候,会调token_tag

# actionview-5.2.4.1/lib/action_view/helpers/form_tag_helper.rb
def form_tag_html(html_options)
  extra_tags = extra_tags_for_form(html_options)
  tag(:form, html_options, true) + extra_tags
end

def extra_tags_for_form(html_options)
  authenticity_token = html_options.delete("authenticity_token")
  method = html_options.delete("method").to_s.downcase

  method_tag = \
    case method
    when "get"
      # ...
    when "post", ""
      html_options["method"] = "post"
      token_tag(authenticity_token, form_options: {
        action: html_options["action"],
        method: "post"
      })
    else
      html_options["method"] = "post"
      method_tag(method) + token_tag(authenticity_token, form_options: {
        action: html_options["action"],
        method: method
      })
    end

  if html_options.delete("enforce_utf8") { true }
    # ...
  else
    method_tag
  end
end

同样,又跑到form_authenticity_token方法里面了,这次不同的地方就是,有了actionmethod

# actionview-5.2.4.1/lib/action_view/helpers/url_helper.rb
def token_tag(token = nil, form_options: {})
  if token != false && protect_against_forgery?
    token ||= form_authenticity_token(form_options: form_options)
    tag(:input, type: "hidden", name: request_forgery_protection_token.to_s, value: token)
  else
    "".freeze
  end
end

在Rails 5中,默认是开启per_form_csrf_tokens。如果是Rails 4升级过来的,就是false。与meta生成的token不同的地方就是,这里会把real_csrf_tokenactionmethod放在一起,做一次加密,赋值给raw_token。后面就和上面meta标签中生成的token的逻辑一样,生成一个one_time_pad,然后和raw_token做异或操作,再把one_time_pad拼到前面去,最后再做一个Base64 encode。

# actionpack-5.2.4.1/lib/action_controller/metal/request_forgery_protection.rb
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
    # ...
  end
  # ...
end

def per_form_csrf_token(session, action_path, method) # :doc:
  OpenSSL::HMAC.digest(
    OpenSSL::Digest::SHA256.new,
    real_csrf_token(session),
    [action_path, method.downcase].join("#")
  )
end

csrf_token的验证

无论是HTML POST请求,还是JavaScript的XHR请求,都会传入authenticity_token,拿这个和session cookie中的csrf_token做对比就行了。

再看看当初在application_controller.rb里面加入的protect_from_forgery,它会加入一个before_actionverify_authenticity_token

# actionpack-5.2.4.1/lib/action_controller/metal/request_forgery_protection.rb
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

验证开始了。verify_authenticity_token中我们需要真正关注的是any_authenticity_token_valid?.

def verify_authenticity_token # :doc:
  # ...

  if !verified_request?
    # ...
  end
end

def verified_request? # :doc:
  !protect_against_forgery? || request.get? || request.head? ||
    (valid_request_origin? && any_authenticity_token_valid?)
end

由参数传过来的authenticity_token和request header中的X-CSRF-TOKEN,只要有一个验证通过即可。

def any_authenticity_token_valid? # :doc:
  request_authenticity_tokens.any? do |token|
    valid_authenticity_token?(session, token)
  end
end

def request_authenticity_tokens # :doc:
  [form_authenticity_param, request.x_csrf_token]
end

真正的验证开始了,其实就是将生成的步骤反过来一次。先将authenticity_token用Base64 decode一次,取前32位位one_time_pad,再和后32位做异或操作,取回csrf_token。这个csrf_token可能是meta 标签中的csrf_token,也可能是表单中的authenticity_token。所以要判断两次,一次compare_with_real_token(csrf_token, session),另外一次valid_per_form_csrf_token?(csrf_token, session)

def valid_authenticity_token?(session, encoded_masked_token) # :doc:
  # ...

  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
    # ...
  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

def unmask_token(masked_token) # :doc:
  # Split the token into the one-time pad and the encrypted
  # value and decrypt it.
  one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
  encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
  xor_byte_strings(one_time_pad, encrypted_csrf_token)
end

对于compare_with_real_token,直接把csrf_tokensession中的csrf_token对比即可。

对于valid_per_form_csrf_token?,用session中的csrf_tokenactionmethod放在一起加密,再把生成的密文和csrf_token做对比。

def compare_with_real_token(token, session) # :doc:
  ActiveSupport::SecurityUtils.fixed_length_secure_compare(token, real_csrf_token(session))
end

def valid_per_form_csrf_token?(token, session) # :doc:
  if per_form_csrf_tokens
    correct_token = per_form_csrf_token(
      session,
      normalize_action_path(request.fullpath),
      request.request_method
    )

    ActiveSupport::SecurityUtils.fixed_length_secure_compare(token, correct_token)
  else
    false
  end
end

基于Rails 5.2, 可以用下面的方法decrypt session cookie。

secret_key_base

对于secret_key_base, Rails 5.2是保存在config/credentials.yml.enc中。该文件是加密的,加密的key是保存在config/master.key中。可以用EDITOR='vi' rails credentials:edit查看。也可以用secrets.yml的方式。还可以用config/initializers/secret_token.rb,直接设置Rails.application.config.secret_key_base = ''。参见Stack Overflow上的回答

FAQ

Q: 为什么同时开多个页面,它们的authenticity_token都是不同的,但是都是有效的?

A: 每次打开新的页面,session中的csrf_token是一样的,但是每次的one_time_pad是不一样的,所以每个authenticity_token都不一样。