使用 Rails 通知监控生产环境中的 CSRF 攻击
Rails 的 CSRF 保护一直在发生可疑情况时记录警告。但日志很容易被忽略。即将到来的一个变更将这些警告转换为 ActiveSupport 通知,让您可以订阅 CSRF 事件并构建真正的安全监控。
即将发生的变化
Rails 将为 CSRF 相关情况发出三个通知事件:
csrf_token_fallback.action_controller— 浏览器未发送Sec-Fetch-Site头,回退到令牌验证csrf_request_blocked.action_controller— 由于 CSRF 验证失败,请求被阻止csrf_javascript_blocked.action_controller— 跨域 JavaScript 请求被阻止
每个事件在其 payload 中包含有用的上下文:
{
request: request, # 完整的 ActionDispatch::Request
controller: "UsersController",
action: "update",
sec_fetch_site: "cross-site", # 或 "same-origin", "same-site", "none", nil
message: "..." # 人类可读的描述(用于阻止事件)
}
为什么这很重要
CSRF 攻击是真实存在的。当有人对您的应用发起攻击时,您希望立即知道——而不是几周后在日志中 grep 时才发现。
使用通知,您可以在被阻止的请求激增时发出警报,追踪哪些端点被攻击以及来自哪里,将事件发送到 Datadog 或您的 SIEM,或者调试为什么您自己的表单无法通过 CSRF 验证。
构建 CSRF 监控器
一个追踪事件并对可疑模式发出警报的订阅器:
# config/initializers/csrf_monitor.rb
class CsrfMonitor < ActiveSupport::Subscriber
attach_to :action_controller
def csrf_token_fallback(event)
# 浏览器未发送 Sec-Fetch-Site 头
# 常见于旧版浏览器、curl 或非浏览器客户端
track_event("fallback", event.payload)
end
def csrf_request_blocked(event)
# 实际的 CSRF 验证失败 - 潜在攻击
track_event("blocked", event.payload)
alert_if_suspicious(event.payload)
end
def csrf_javascript_blocked(event)
# 有人试图从另一个来源加载您的 JS
track_event("js_blocked", event.payload)
end
private
def track_event(type, payload)
Rails.logger.tagged("CSRF") do
Rails.logger.warn({
type: type,
controller: payload[:controller],
action: payload[:action],
sec_fetch_site: payload[:sec_fetch_site],
ip: payload[:request]&.remote_ip,
origin: payload[:request]&.origin,
user_agent: payload[:request]&.user_agent
}.to_json)
end
# 发送到您的指标系统
StatsD.increment("csrf.#{type}", tags: [
"controller:#{payload[:controller]}",
"action:#{payload[:action]}"
]) if defined?(StatsD)
end
def alert_if_suspicious(payload)
# 如果我们看到来自同一 IP 的多个被阻止请求则发出警报
# 注意:需要 Redis 或 Memcached 缓存存储以进行原子增量
ip = payload[:request]&.remote_ip
return unless ip
cache_key = "csrf_blocked:#{ip}"
count = Rails.cache.increment(cache_key, 1, expires_in: 1.hour, raw: true) || 1
if count >= 10
SecurityAlertJob.perform_later(
type: "csrf_attack_suspected",
ip: ip,
count: count,
last_target: "#{payload[:controller]}##{payload[:action]}"
)
end
end
end
向外部服务发送事件
Datadog
class CsrfDatadogSubscriber < ActiveSupport::Subscriber
attach_to :action_controller
def csrf_request_blocked(event)
Datadog::Tracing.trace("csrf.blocked") do |span|
span.set_tag("controller", event.payload[:controller])
span.set_tag("action", event.payload[:action])
span.set_tag("origin", event.payload[:request]&.origin)
span.set_tag("sec_fetch_site", event.payload[:sec_fetch_site])
end
end
end
错误追踪服务
class CsrfErrorSubscriber < ActiveSupport::Subscriber
attach_to :action_controller
def csrf_request_blocked(event)
ErrorTracker.capture("CSRF request blocked",
level: :warning,
tags: {
controller: event.payload[:controller],
action: event.payload[:action]
},
context: {
origin: event.payload[:request]&.origin,
sec_fetch_site: event.payload[:sec_fetch_site],
ip: event.payload[:request]&.remote_ip
}
)
end
end
将 ErrorTracker.capture 替换为您实际使用的服务——Sentry、Honeybadger、Bugsnag 等。
一个注意点:如果没有自定义指纹(类似 ["csrf", controller, action]),您最终会得到数千个重复问题。高流量应用还应实施采样以避免耗尽配额。
避免重复日志条目
Rails 内置的 LogSubscriber 已经在监听这些 CSRF 事件并记录警告。如果您构建自己的订阅器,您将得到重复的条目——一个来自 Rails,一个来自您的。
要禁用 Rails 默认的 CSRF 日志:
# config/application.rb
config.action_controller.log_warning_on_csrf_failure = false
这只会禁用内置的日志输出——您的自定义订阅器仍然接收所有事件。通知始终会触发。
当您自己处理日志、转发到外部服务、或者厌倦了 API 客户端和旧浏览器产生的嘈杂回退事件时,可以禁用它。
理解事件
令牌回退
csrf_token_fallback 事件在浏览器不发送 Sec-Fetch-Site 头时触发。现代浏览器会自动发送此头,因此此事件通常意味着:
- 旧版浏览器(2020 年之前)
- API 客户端或脚本(curl、Postman 等)
- 带有剥离头部的隐私扩展的浏览器
这不一定是攻击——这是信息性的。但如果您从应该是现代浏览器的地方看到这个,请调查。
请求被阻止
csrf_request_blocked 事件是重要的。它在以下情况触发:
- CSRF 令牌完全缺失
- CSRF 令牌无效或过期
- Origin 头与允许的来源不匹配
Sec-Fetch-Site表示跨站且来源不受信任
合法原因包括过期的会话、表单重复提交或配置错误的 CORS。非法原因是实际的 CSRF 攻击。
JavaScript 被阻止
csrf_javascript_blocked 事件在有人试图从另一个来源包含您的 JavaScript 时触发。这几乎总是可疑的——要么是攻击,要么是配置错误的 CDN。
静默噪音事件
某些端点在正常操作期间触发 CSRF 事件——刷新令牌的移动应用、具有竞态条件的 JavaScript 表单、传统集成。您可以过滤它们:
class CsrfMonitor < ActiveSupport::Subscriber
attach_to :action_controller
IGNORED_ENDPOINTS = [
["SessionsController", "refresh"], # 移动应用令牌刷新
["Api::V1::SyncController", nil], # nil 匹配任何操作
["LegacyIntegrationController", "callback"]
].freeze
def csrf_request_blocked(event)
return if ignored_endpoint?(event.payload)
# ... 处理事件
end
private
def ignored_endpoint?(payload)
IGNORED_ENDPOINTS.any? do |controller, action|
payload[:controller] == controller &&
(action.nil? || payload[:action] == action)
end
end
end
测试您的订阅器
# test/subscribers/csrf_monitor_test.rb
require "test_helper"
class CsrfMonitorTest < ActiveSupport::TestCase
MockRequest = Struct.new(:remote_ip, :origin, :user_agent, keyword_init: true)
test "tracks blocked requests" do
payload = {
controller: "UsersController",
action: "update",
sec_fetch_site: "cross-site",
request: mock_request(remote_ip: "1.2.3.4"),
message: "CSRF token verification failed"
}
assert_logs_matching(/CSRF.*UsersController/) do
ActiveSupport::Notifications.instrument(
"csrf_request_blocked.action_controller",
payload
)
end
end
test "alerts after repeated blocked requests from same IP" do
payload = {
controller: "UsersController",
action: "update",
request: mock_request(remote_ip: "1.2.3.4")
}
assert_enqueued_with(job: SecurityAlertJob) do
10.times do
ActiveSupport::Notifications.instrument(
"csrf_request_blocked.action_controller",
payload
)
end
end
end
private
def mock_request(attrs = {})
MockRequest.new(**{
remote_ip: "127.0.0.1",
origin: "https://evil.com",
user_agent: "Mozilla/5.0"
}.merge(attrs))
end
def assert_logs_matching(pattern, &block)
old_logger = Rails.logger
log_output = StringIO.new
Rails.logger = ActiveSupport::TaggedLogging.new(Logger.new(log_output))
yield
assert_match pattern, log_output.string
ensure
Rails.logger = old_logger
end
end
在开发环境中触发事件
从 Rails 控制台手动触发事件来验证您的订阅器是否正常工作:
# 模拟被阻止的 CSRF 请求
ActiveSupport::Notifications.instrument(
"csrf_request_blocked.action_controller",
controller: "TestController",
action: "create",
sec_fetch_site: "cross-site",
message: "CSRF token verification failed"
)
# 模拟令牌回退
ActiveSupport::Notifications.instrument(
"csrf_token_fallback.action_controller",
controller: "TestController",
action: "update",
sec_fetch_site: nil
)
或者通过提交没有有效令牌的表单来触发真正的 CSRF 失败:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "user[name]=test"
总结
这将在 Rails 8.2 中发布。如果您现在就想用,将 Gemfile 指向 main。PR #56355 有详细信息。