使用 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 有详细信息。