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