Rails 的 CSRF 防护并不是什么魔法黑盒,它本质上是一套基于 Token 验证的机制,代码实现主要集中在 "ActionController::RequestForgeryProtection" 这个模块里。当你新建一个 Rails 应用,ApplicationController 默认就会包含 "protect_from_forgery" 这行代码,这意味着除了明确跳过的 Action,所有非 GET 请求都会被强制校验 CSRF Token。这个 Token 的生成、存储、比对,全在 Rails 源码里有迹可循。
先看 Token 是怎么生成的。在 "request_forgery_protection.rb" 中,核心方法是 "form_authenticity_token"。它并不是每次请求都随机生成一个新 Token,那样会导致后退页面、多标签页等场景直接报错。Rails 的做法是每个 Session 生成一个固定的 "real_csrf_token",然后结合一次性 Pad 做掩码处理。具体代码逻辑在 "real_csrf_token" 方法里,它调用 "SecureRandom.base64(32)" 生成一个 32 字节的随机字符串,存入 Session 的 "_csrf_token" 键中。这个值在整个会话生命周期内保持不变,除非你主动重置 Session。
def real_csrf_token(session) session[:_csrf_token] ||= SecureRandom.base64(AUTHENTICITY_TOKEN_LENGTH) Base64.strict_decode64(session[:_csrf_token]) end
但直接把这个原始 Token 嵌入表单存在安全风险,因为攻击者如果能通过其他漏洞读取到页面内容,就能拿到这个固定值。所以 Rails 引入了掩码 Token 的概念。"form_authenticity_token" 方法会生成一个一次性随机字符串 "one_time_pad",长度和原始 Token 一致,然后把两者做异或运算,最终返回 Base64 编码的 "one_time_pad + 异或结果"。每次调用这个方法,都会生成新的 "one_time_pad",所以同一个 Session 下,每次渲染表单拿到的 Token 字符串都不一样,但服务端都能还原出原始 Token 进行比对。
def masked_authenticity_token(session) one_time_pad = SecureRandom.random_bytes(AUTHENTICITY_TOKEN_LENGTH) encrypted_csrf_token = xor_byte_strings(one_time_pad, real_csrf_token(session)) masked_token = one_time_pad + encrypted_csrf_token Base64.strict_encode64(masked_token) end
Token 生成后,Rails 通过多种渠道把它送到前端。最常见的是通过 "csrf_meta_tags" 辅助方法,在页面 head 里生成两个 meta 标签,一个叫 "csrf-param",值是参数名 "authenticity_token";另一个叫 "csrf-token",值就是掩码后的 Token。同时,所有通过 Rails 表单辅助方法生成的 form 标签,内部会自动插入一个 hidden input,name 为 "authenticity_token",value 同样是掩码 Token。如果你用 Rails 的 UJS 驱动 Ajax 请求,"jquery_ujs" 或 "rails-ujs" 会自动从 meta 标签读取 Token,附加到每个 Ajax 请求的 "X-CSRF-Token" 请求头里。
Token 到了前端,提交请求时怎么传回来,Rails 也设计了多种兼容路径。服务端验证时,"verify_authenticity_token" 方法会按优先级从三个地方查找 Token:请求头的 "X-CSRF-Token" 字段、表单参数里的 "authenticity_token"、以及 URL 查询字符串里的 "authenticity_token"。这个查找逻辑在 "any_authenticity_token_valid?" 方法中体现得很清楚,它会遍历这些来源,只要有一个能通过验证就算成功。
def any_authenticity_token_valid?
request_authenticity_tokens.any? do |token|
valid_authenticity_token?(session, token)
end
end
def request_authenticity_tokens
[form_authenticity_param, request.x_csrf_token]
end
拿到前端传来的掩码 Token 后,解码比对的过程在 "valid_authenticity_token?" 方法里。它先把 Base64 解码成原始字节,然后拆分成两部分:前 32 字节是 "one_time_pad",后 32 字节是加密后的 Token。把这两部分做异或运算,就还原出了实际传递的 Token。最后用 "ActiveSupport::SecurityUtils.secure_compare" 把这个还原后的值与 Session 里存储的原始 Token 做常量时间比较,防止时序攻击。这个方法之所以用 "secure_compare" 而不是简单的 "==",是因为普通字符串比较在遇到第一个不同字符时就会返回,攻击者可以通过测量响应时间来逐字节猜测正确 Token,而常量时间比较会遍历完整个字符串再返回结果,彻底堵死了这个侧信道。
def valid_authenticity_token?(session, encoded_masked_token)
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
return false
end
if masked_token.length != AUTHENTICITY_TOKEN_LENGTH * 2
return false
end
one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
csrf_token = xor_byte_strings(one_time_pad, encrypted_csrf_token)
stored_csrf_token = real_csrf_token(session)
ActiveSupport::SecurityUtils.secure_compare(csrf_token, stored_csrf_token)
end
这里有一个容易被忽略的细节:Rails 在解码失败或长度不对时会直接返回 false,而不是抛出异常。这是故意为之,避免攻击者通过观察应用是否报 500 错误来判断 Token 格式是否接近正确,属于纵深防御的一部分。
除了默认的 Session 存储方式,Rails 还支持把 Token 存在 Cookie 里,这通过 "protect_from_forgery with: :exception, store: :cookie" 来开启。这种模式下,"real_csrf_token" 不再从 Session 读取,而是从名为 "_csrf_token" 的加密 Cookie 中读取。Cookie 模式的好处是服务端完全无状态,不需要依赖 Session 存储,适合 API 网关或多服务器部署场景。但代价是每次请求都要解密 Cookie,而且 Token 的生命周期和 Cookie 一致,如果 Cookie 被盗,防护就形同虚设。源码中 "cookie_based_real_csrf_token" 方法处理了这个逻辑,它会用 "cookies.encrypted" 来读写,确保 Token 在客户端也是加密存储的。
对于纯 API 应用,Rails 默认会跳过 CSRF 验证,因为 API 通常用 Token 认证而非 Cookie 维持会话,CSRF 攻击的前提条件不成立。但如果你在 API 中混用了 Cookie 认证,就必须手动开启。另外,Rails 5.2 之后引入的 "per_form_csrf_tokens" 选项值得关注。开启后,每个表单会生成独立的 CSRF Token,基于表单的 Action 和 Method 做 HMAC 签名。这意味着即使某个表单的 Token 泄露,攻击者也没法用它伪造其他表单的请求,颗粒度从全局细化到了单个表单级别。
def per_form_csrf_token(session, action_path, method)
raw_token = real_csrf_token(session)
message = "#{action_path}:#{method}"
OpenSSL::HMAC.digest('SHA256', raw_token, message)
end
还有一个实战中常见的坑:当你用 "config.action_controller.default_protect_from_forgery" 设为 true 后,所有控制器默认开启保护,但如果你在某个控制器里又写了 "protect_from_forgery",它会覆盖全局设置。更隐蔽的是,"skip_before_action :verify_authenticity_token" 这行代码一旦出现,整个控制器或指定 Action 就完全绕过了 CSRF 检查,这在代码审查时应该作为高危模式重点关注。Rails 源码中 "verify_authenticity_token" 方法本身只是一个 "before_action" 回调,它没有做任何特殊标记,所以跳过它就是字面意义上的完全不检查。
从 Rails 7 开始,框架还引入了 "authenticity_token" 的自动轮换机制。当 Token 验证失败时,Rails 不会立即返回 422,而是会尝试用旧 Token 再验证一次,如果旧 Token 有效,就自动更新 Session 中的 Token 并让请求通过。这个逻辑在 "handle_unverified_request" 方法中实现,目的是解决用户在多个标签页操作时,其中一个标签页提交表单导致 Token 失效,其他标签页跟着报错的问题。这个改进让用户体验更平滑,但也增加了攻击面,因为攻击者多了一次尝试机会。不过由于掩码机制和常量时间比较的存在,这个额外机会在安全上不构成实质威胁。
最后说一个源码层面暴露出的设计哲学:Rails 的 CSRF 防护不是靠某个单一技巧,而是通过掩码 Token、常量时间比较、多来源查找、自动轮换、单表单 Token 等多层机制叠加,形成纵深防御。每一层单独看似乎都有绕过可能,但组合起来就让攻击成本变得极高。理解这些源码细节,不仅能帮你排查生产环境中诡异的 "ActionController::InvalidAuthenticityToken" 错误,还能让你在做安全审计时,清楚地知道每个配置项背后到底动了哪些代码路径。
