Rails通知機能で本番環境のCSRF攻撃を監視する

RailsのCSRF保護は、不審な動きがあると常に警告をログに記録してきました。しかし、ログは見落としやすいものです。今後の変更により、これらの警告がActiveSupportの通知に変換され、CSRFイベントを購読して本格的なセキュリティ監視を構築できるようになります。

何が変わるか

RailsはCSRF関連の状況に対して3つの通知イベントを発行するようになります:

  • csrf_token_fallback.action_controller — ブラウザがSec-Fetch-Siteヘッダーを送信せず、トークン検証にフォールバック
  • csrf_request_blocked.action_controller — CSRF検証の失敗によりリクエストがブロックされた
  • csrf_javascript_blocked.action_controller — クロスオリジンのJavaScriptリクエストがブロックされた

各イベントはペイロードに有用なコンテキストを含みます:

{
  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に詳細があります。