Surveillez les Attaques CSRF en Production avec les Notifications Rails
La protection CSRF de Rails a toujours enregistré des avertissements lorsque quelque chose de suspect se produit. Mais les logs sont faciles à manquer. Un changement à venir convertit ces avertissements en notifications ActiveSupport, vous permettant de vous abonner aux événements CSRF et de construire une véritable surveillance de sécurité.
Ce Qui Change
Rails émettra trois événements de notification pour les situations liées au CSRF :
csrf_token_fallback.action_controller— Le navigateur n’a pas envoyé l’en-têteSec-Fetch-Site, recours à la vérification par tokencsrf_request_blocked.action_controller— La requête a été bloquée en raison d’un échec de validation CSRFcsrf_javascript_blocked.action_controller— Une requête JavaScript cross-origin a été bloquée
Chaque événement inclut un contexte utile dans son payload :
{
request: request, # L'ActionDispatch::Request complet
controller: "UsersController",
action: "update",
sec_fetch_site: "cross-site", # ou "same-origin", "same-site", "none", nil
message: "..." # Description lisible (pour les événements bloqués)
}
Pourquoi C’est Important
Les attaques CSRF sont réelles. Quand quelqu’un en tente une contre votre application, vous voulez le savoir—pas le découvrir des semaines plus tard en cherchant dans les logs avec grep.
Avec les notifications vous pouvez alerter quand les requêtes bloquées augmentent, suivre quels endpoints sont ciblés et depuis où, envoyer les événements à Datadog ou votre SIEM, ou déboguer pourquoi vos propres formulaires échouent la validation CSRF.
Construire un Moniteur CSRF
Un subscriber qui suit les événements et alerte sur des patterns suspects :
# config/initializers/csrf_monitor.rb
class CsrfMonitor < ActiveSupport::Subscriber
attach_to :action_controller
def csrf_token_fallback(event)
# Le navigateur n'a pas envoyé l'en-tête Sec-Fetch-Site
# Courant pour les anciens navigateurs, curl, ou clients non-navigateur
track_event("fallback", event.payload)
end
def csrf_request_blocked(event)
# Échec réel de validation CSRF - attaque potentielle
track_event("blocked", event.payload)
alert_if_suspicious(event.payload)
end
def csrf_javascript_blocked(event)
# Quelqu'un a essayé de charger votre JS depuis une autre origine
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
# Envoyer à votre système de métriques
StatsD.increment("csrf.#{type}", tags: [
"controller:#{payload[:controller]}",
"action:#{payload[:action]}"
]) if defined?(StatsD)
end
def alert_if_suspicious(payload)
# Alerter si nous voyons plusieurs requêtes bloquées depuis la même IP
# Note : nécessite Redis ou Memcached comme cache store pour l'incrément atomique
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
Envoi d’Événements vers des Services Externes
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
Services de Suivi d’Erreurs
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
Remplacez ErrorTracker.capture par votre service réel—Sentry, Honeybadger, Bugsnag, ce que vous utilisez.
Un point important : sans empreinte personnalisée (quelque chose comme ["csrf", controller, action]), vous vous retrouverez avec des milliers d’issues en double. Les applications à fort trafic devraient également échantillonner pour ne pas épuiser les quotas.
Éviter les Entrées de Log en Double
Le LogSubscriber intégré de Rails écoute déjà ces événements CSRF et enregistre des avertissements. Si vous construisez votre propre subscriber, vous obtiendrez des entrées en double—une de Rails, une de la vôtre.
Pour désactiver le logging CSRF par défaut de Rails :
# config/application.rb
config.action_controller.log_warning_on_csrf_failure = false
Cela ne supprime que la sortie de log intégrée—vos subscribers personnalisés reçoivent toujours tout. Les notifications se déclenchent quoi qu’il arrive.
Désactivez-le quand vous gérez le logging vous-même, transmettez à des services externes, ou en avez assez des événements bruyants de fallback des clients API et anciens navigateurs.
Comprendre les Événements
Token Fallback
L’événement csrf_token_fallback se déclenche quand un navigateur n’envoie pas l’en-tête Sec-Fetch-Site. Les navigateurs modernes envoient cet en-tête automatiquement, donc cet événement signifie généralement :
- Ancien navigateur (pré-2020)
- Client API ou script (curl, Postman, etc.)
- Navigateur avec des extensions de confidentialité qui suppriment les en-têtes
Ce n’est pas nécessairement une attaque—c’est informatif. Mais si vous voyez cela depuis ce qui devrait être un navigateur moderne, enquêtez.
Requête Bloquée
L’événement csrf_request_blocked est l’important. Il se déclenche quand :
- Le token CSRF est complètement absent
- Le token CSRF est invalide ou expiré
- L’en-tête Origin ne correspond pas aux origines autorisées
Sec-Fetch-Siteindique cross-site et l’origine n’est pas de confiance
Les causes légitimes incluent les sessions expirées, les soumissions de formulaires en double, ou un CORS mal configuré. Les causes illégitimes sont de véritables attaques CSRF.
JavaScript Bloqué
L’événement csrf_javascript_blocked se déclenche quand quelqu’un essaie d’inclure votre JavaScript depuis une autre origine. C’est presque toujours suspect—soit une attaque, soit un CDN mal configuré.
Réduire les Événements Bruyants
Certains endpoints déclenchent des événements CSRF pendant le fonctionnement normal—applications mobiles rafraîchissant les tokens, formulaires JavaScript avec des conditions de concurrence, intégrations legacy. Vous pouvez les filtrer :
class CsrfMonitor < ActiveSupport::Subscriber
attach_to :action_controller
IGNORED_ENDPOINTS = [
["SessionsController", "refresh"], # Rafraîchissement de token d'app mobile
["Api::V1::SyncController", nil], # nil correspond à n'importe quelle action
["LegacyIntegrationController", "callback"]
].freeze
def csrf_request_blocked(event)
return if ignored_endpoint?(event.payload)
# ... gérer l'événement
end
private
def ignored_endpoint?(payload)
IGNORED_ENDPOINTS.any? do |controller, action|
payload[:controller] == controller &&
(action.nil? || payload[:action] == action)
end
end
end
Tester Votre Subscriber
# 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
Déclencher des Événements en Développement
Déclenchez des événements manuellement depuis la console Rails pour vérifier que votre subscriber fonctionne :
# Simuler une requête CSRF bloquée
ActiveSupport::Notifications.instrument(
"csrf_request_blocked.action_controller",
controller: "TestController",
action: "create",
sec_fetch_site: "cross-site",
message: "CSRF token verification failed"
)
# Simuler un token fallback
ActiveSupport::Notifications.instrument(
"csrf_token_fallback.action_controller",
controller: "TestController",
action: "update",
sec_fetch_site: nil
)
Ou déclenchez un véritable échec CSRF en soumettant un formulaire sans token valide :
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "user[name]=test"
Conclusion
Ceci arrive dans Rails 8.2. Si vous le voulez maintenant, pointez votre Gemfile vers main. PR #56355 contient les détails.