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ête Sec-Fetch-Site, recours à la vérification par token
  • csrf_request_blocked.action_controller — La requête a été bloquée en raison d’un échec de validation CSRF
  • csrf_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-Site indique 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.