如果你现在在使用 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 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 返回到页面中了。
当这些操作完成后,加密好的认证 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 魔法的时候,去看一看魔法下面的真实样子吧。