翻译 深入 Rails 中的 CSRF Protection

heylonj · March 09, 2018 · Last by zhongxiao37 replied at July 29, 2020 · 14391 hits
Topic has been selected as the excellent topic by the admin.

如果你现在在使用 Rails 进行开发,你很可能就正在使用 CSRF protection。它几乎是在一开始的时候就有的特性,而且就像一些其他的 Rails 特性一样,它会使你的生活更加美好,基本上你都不用考虑它具体做了什么。

简单来说,跨站请求伪造 (Cross-site request forgery,通常缩写为 CSRF) 就是一个未认证用户伪造了一个数据请求并把它发送到服务器,让服务器认为这个请求是发自一个已认证用户。Rails 会生成一个唯一的 token,并且在每一次接到客户端请求时对请求进行身份验证(注:可以简单说验证这个请求是不是在我们的网站上正常发起的),以避免受到 CSRF 攻击。

最近,我在 Unbounce 工作中有一个功能需要我考虑如何解决客户端 Javascript 的请求。这个过程让我发现我当时对 CSRF 的了解仅仅停留在它的字面意思上。

我决定深入的看一下 Rails 的源码以了解 Rails 是如何对这种攻击进行防护的。接下来我会分享一下在这次对 Rails 中的 CSRF Protection 探索中所发现的信息。我们将看到 token 是如何在每次服务器相应中被生成的,以及在接下来的请求中如何使用 token 来验证客户端身份的。

基础知识

Rails 中 CSRF 分别存储在两个地方。一个唯一的 token 会被插入到 HTML 中。同时会有一个相同的 token 会被储存在 session cookie 中。当用户发送一个 POST 请求时,会把 CSRF token 从 HTML 中拿出来放到请求中发送到服务器。Rails 会比对 session cookie 中的 token 和 请求中带有的从 HTML 获取的 token 是否匹配。

Rails对两个CSRF进行比对

如何使用

作为 Rails 开发者,你什么都不用做就可以使用 CSRF protection. 在 application_controller.rb 中有以下一行代码,它开启了 Rails 的 CSRF protection:

protect_from_forgery with: :exception

然后,在application.html.erb文件中,你会发现下面这行代码:

<%= csrf_meta_tags %>

就是这些,它已经在 Rails 中很久了,而且我们几乎不需要管它。但 Rails 究竟是如何实现的呢?

生成和加密

我们从 #csrf_meta_tags开始。这是一个简单的 helper 方法,它会把 CSRF token 插入到 HTML 中。

# actionview/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

这个 csrf-token 标签就是我们需要关注的,它就是魔法发生的地方。其中#form_authenticity_token方法就是获取到真正的 token 地方。接下来我们就要进入到 RequestForgeryProtection 这个 module 内部了。激动的时刻开始了。

RequestForgeryProtection module 处理了所有的与 CSRF 有关的事。其中包括之前我们在 ApplicationController 中看到的最著名的#protect_from_forgery方法,这个方法会设置一些 hook 来确保 CSRF token 的校验能在每次请求时都会被触发,以及当校验失败时该如何响应。而且它也会关系到 CSRF token 的生成,加密与解析。让我最喜欢的是这个 module 所涉及的范围很小,在这一个文件中,顺着一些 view 的 helper 方法往下看,你会看到 CSRF protection 的整个实现。

让我们继续深入 CSRF token 直到它被插入到 HTML 中。#form_authenticity_token 是一个简单的 wrapper 方法,它接受任意的 hash 参数,也包括 session 本身,继续往下会看到#masked_authenticity_token方法:

# actionpack/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

# Creates a masked version of the authenticity token that varies
# on each request. The masking is used to mitigate SSL attacks
# like BREACH.
def masked_authenticity_token(session, form_options: {}) # :doc:
  # ...
  raw_token = if per_form_csrf_tokens && action && 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

根据per-form CSRF tokens in Rails5中的介绍,#masked_authenticity_token 方法已经变得有点复杂了。因为这次我们的目的更关注原始的“每次请求一个 CSRF token”实现,也就是之前提到的 meta 标签中的那个,所以我们可以先只关注方法中else的部分,在这个部分中调用了#real_csrf_token,并把值返回给raw_token

为什么我们需要把session传入#real_csrf_token方法中呢?这个方法做了两件事:首先生成一个未被加密处理的 token,然后把这个 token 插入到 session cookie 中:

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

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

还记得这个方法最初是我们在 application 模板中调用的#csrf_meta_tags吗,这是一个经典的 Rails 魔法 -- 它保证了 session cookie 中的 token 和页面上的 token 总是匹配的,因为每一次生成的 token 都是先储存在 session cookie 中,然后再把存在 session cookie 中的 token 插入到页面中的。

让我们看一下#masked_authenticity_token方法的最后部分:

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)

是时候进行一些加密工作了。现在已经把 token 插入到了 session cookie,接下来方法就要解决到怎样把 token 返回到 HTML 的了,这里我们还提前做了些预防(主要是降低 SSL 漏洞攻击的可能性,现在我不会涉及到这些)。需要注意的是 session cookie 中的 token 是没有经过我们的加密的,因为从 Rails4 开始 session cookie 默认就会被加密了。

首先,我们生成了一次性密钥(one-time pad),我们将要使用它来加密 token。一次性密钥是一种 使用随机生成的密钥来对定长的纯文本进行加密的方法,并且需要这个密钥对已经加密过的消息进行解密操作。之所以它被称作“一次性”密钥,是因为每以个密钥只用于加密一个文本,然后就会被销毁。Rails 就是为每一个新的 CSRF token 都重新生成一个一次性密钥,然后使用它来对 token 进行 XOR 按位异或操作。再将一次性密钥拼接到上一步得到的字符串前面进行混淆,然后使用 Base64 进行编码,这样就可以把加密好的 token 返回到页面中了。

Rails CSRF 加密过程

当这些操作完成后,加密好的认证 token 就会返回到堆栈中,回传到 application 模板中:

<meta name="csrf-param" content="authenticity_token" />
<meta name="csrf-token" content="vtaJFQ38doX0b7wQpp0G3H7aUk9HZQni3jHET4yS8nSJRt85Tr6oH7nroQc01dM+C/dlDwt5xPff5LwyZcggeg==" />

解析和验证

我们已经看到了 CSRF token 的生成过程,以及它是如何被存入到 HTML 和 cookie 中的,现在该让我们看看 Rails 在收到的请求时是怎样进行验证的。

当用户在你的站点提交一个表单时,CSRF token 就会与表单数据一起提交 (默认是名为authenticity_token的参数),也可以将它放在 HTTP 头X-CSRF-Token中发送。

回忆一下在 ApplicationController 中的这行代码:

protect_from_forgery with: :exception

其中,#protect_from_forgery方法会在每一个 controller action 的生命周期中添加 before_action:

before_action :verify_authenticity_token, options

这个 before action 会将 params 或 header 中传来的 CSRF token 与 session cookie 中的 token 进行比较。

# actionpack/lib/action_controller/metal/request_forgery_protection.rb

def verify_authenticity_token # :doc:
  # ...
  if !verified_request?
    # handle errors ...
  end
end

# ...

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

在一些判断之后(比如我们不需要验证 HEAD 和 GET 的请求),真正的验证发生#any_authenticity_token_valid?方法中:

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

因为 token 可能在请求的 params 或者 header 中,Rails 要求 params 和 header 的 token 中至少有一个与 session cookie 中的 token 相匹配才算通过验证。

#valid_authenticity_token是很长的一个方法,但其实它只是把#masked_authenticity_token方法中的操作反过来做一遍,先对 token 进行解析然后再做校验:

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

  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

首先,我们需要对已经被 Base64 编码过的字符串进行解码,解码后我们得到了被混淆过的 token,然后去掉之前加入的混淆,再将得到的 token 与 session 中的 token 进行对比:

def unmask_token(masked_token) # :doc:
  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

#unmask_token方法中,我们需要把混淆过的 token 拆分为原来的两部分:一次性密钥和加密后的 token。然后再对这两个字符串进行 XOR 操作,最后得到了 Rails 在最开始生成的明文 token。

最后,在#compare_with_real_token方法使用 ActiveSupport::SecureUtils 来对两个 token 进行比较:

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

最后,我们的请求顺利的通过验证啦~

总结

我之前从来没有对 CSRF 有过这么多的思考,Rails 其实已经帮我们做了很多事,很多时候我们总是会觉得 它“这样就可以工作了”。每一次去探索这些魔法背后的实现过程都是很美妙的。

我认为 CSRF protection 的实现是一个很好的对代码进行职责拆分的例子,通过创建一个 module 来暴露一个简单的而稳定的公共接口,底层的实现可以随意做一些小修改也不会影响到其余的源代码 -- 而且你可以看到在这些年 Rails 团队为 CSRF protection 添加的一些功能,就比如 per-form tokens。

在每一次深入 Rails 源码的过程中我都会学到很多。我希望这些也会激励着你,在遇到一些 Rails 魔法的时候,去看一看魔法下面的真实样子吧。

英文原文:A Deep Dive into CSRF Protection in Rails

jasl mark as excellent topic. 09 Mar 01:05

额,突然想到 16 年年底面试,我说 ajax 会触发跨域请求 (csrf),他说不会。

Cross-Site Request Forgery and Rails

Reply to xiaohesong

跨域是 CORS

Reply to Rei

嗯 但是CSRF我认为也是为了防止跨域请求加上的限制。

Reply to xiaohesong

ajax 未必会触发跨域请求,甚至一般情况下不会,所以他没错啊

说的是没有错啊,所以我发了那个链接啊。

Reply to xiaohesong

CSRF 和 CORS 没有必然关系,CSRF 有多种攻击方式 html 资源标签、form 表单提交、JS 代码发起请求,也许攻击方式涉及到了跨域

楼主,有一个问题我一直有疑问,一个 csrf token 进行多次 ajax 提交为何还是有效的?

Reply to ecloud

虽然不太清楚你所说的具体实现,但我想你说的多次使用相同的 CSRF token 进行 ajax 提交过程中,session cookie 中的 token 也是没有改变的吧,一般也都是通过在模板中调用#csrf_meta_tags方法来产生新的 CSRF token 的。

感谢翻译分享。

Reply to ecloud

这个没影响的。因为 cookie 内容的不可读性,csrf token 是不会被暴露的。所以你的问题应该问的是请求随机码吧

Reply to heylonj

比如我同时打开两个 ruby china 的页面,可以发现两个页面的 csrf token 不一样,如下

但是两个 token 都是有效的,我有点疑惑。

实际上 Rails 3 才引入了 CSRF Token 保护,之前都是自己实现的。

Reply to ane

懂了,感谢。

翻得不错。 CSRF token 在使用 http only cookie 的场景里起到了保护会话的作用。避免攻击者在别的域名下使用用户的会话发起带副作用的 POST 请求 如果简单总结这个模块干嘛的话:

  1. CSRF 模块会给每个 session 生成一串随机字节,为了讨论方便,称为 A。
  2. 要拿这个 token 的时候(例如调用 form_authenticity_token),再生成另外一串随机字节,称为 B。让 A 和 B 做一下做一下位异或操作,得到 C。最后把 B 和 C 拼接在一起得到 D。D 就是返回的 csrf token。由于用了 B 做了异或,这样每次请求里的 csrf token 都不一样了。
  3. 要判断浏览器发过来的 token 对不对时,假设发送过来的是 E,这 E 其实就是第二步的 D,把 E 分开得到第二步所述的 B 和 C。再做一次异或操作,A 就回来了!最后做一下比较,就得到结果啦。

关于最后的比较 (secure_compare),额外的 TIP:为了防止 Timing Attack 这种旁道攻击,一般来说,都会对字符串做同长比较。rails 会对字符串做个摘要,这样不管字符串多长,生成出来的摘要都是一样长的。这样就可以做同长比较了。

Reply to ecloud

同一个 session 周期内,token 是不变的

Reply to wwwicbd

是的,感谢🙏,我之前没有完全看完这篇文章就发问,惭愧。

Rails 5.2 中有了新的改动,添加了一下新的部分,更新到了 http://zhongxiao37.github.io/rails/2020/07/29/authenticity-token-csrf-token-in-rails-5.html。希望大家指正。

You need to Sign in before reply, if you don't have an account, please Sign up first.