Monitorea Ataques CSRF en Producción con Notificaciones de Rails

La protección CSRF de Rails siempre ha registrado advertencias cuando algo sospechoso ocurre. Pero los logs son fáciles de pasar por alto. Un próximo cambio convierte estas advertencias en notificaciones de ActiveSupport, permitiéndote suscribirte a eventos CSRF y construir un monitoreo de seguridad real.

Qué Está Cambiando

Rails emitirá tres eventos de notificación para situaciones relacionadas con CSRF:

  • csrf_token_fallback.action_controller — El navegador no envió el header Sec-Fetch-Site, recurriendo a la verificación por token
  • csrf_request_blocked.action_controller — La solicitud fue bloqueada debido a un fallo en la validación CSRF
  • csrf_javascript_blocked.action_controller — Una solicitud JavaScript de origen cruzado fue bloqueada

Cada evento incluye contexto útil en su payload:

{
  request: request,           # El ActionDispatch::Request completo
  controller: "UsersController",
  action: "update",
  sec_fetch_site: "cross-site",  # o "same-origin", "same-site", "none", nil
  message: "..."              # Descripción legible (para eventos bloqueados)
}

Por Qué Esto Importa

Los ataques CSRF son reales. Cuando alguien intenta uno contra tu aplicación, quieres saberlo—no descubrirlo semanas después mientras buscas en los logs con grep.

Con las notificaciones puedes alertar cuando las solicitudes bloqueadas aumentan, rastrear qué endpoints están siendo atacados y desde dónde, enviar eventos a Datadog o tu SIEM, o depurar por qué tus propios formularios están fallando la validación CSRF.

Construyendo un Monitor CSRF

Un suscriptor que rastrea eventos y alerta sobre patrones sospechosos:

# config/initializers/csrf_monitor.rb
class CsrfMonitor < ActiveSupport::Subscriber
  attach_to :action_controller

  def csrf_token_fallback(event)
    # El navegador no envió el header Sec-Fetch-Site
    # Común en navegadores antiguos, curl, o clientes no-navegador
    track_event("fallback", event.payload)
  end

  def csrf_request_blocked(event)
    # Fallo real de validación CSRF - potencial ataque
    track_event("blocked", event.payload)
    alert_if_suspicious(event.payload)
  end

  def csrf_javascript_blocked(event)
    # Alguien intentó cargar tu JS desde otro origen
    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

    # Enviar a tu sistema de métricas
    StatsD.increment("csrf.#{type}", tags: [
      "controller:#{payload[:controller]}",
      "action:#{payload[:action]}"
    ]) if defined?(StatsD)
  end

  def alert_if_suspicious(payload)
    # Alertar si vemos múltiples solicitudes bloqueadas desde la misma IP
    # Nota: requiere Redis o Memcached como cache store para incremento atómico
    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

Enviando Eventos a Servicios Externos

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

Servicios de Seguimiento de Errores

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

Cambia ErrorTracker.capture por tu servicio real—Sentry, Honeybadger, Bugsnag, lo que uses.

Un detalle: sin un fingerprint personalizado (algo como ["csrf", controller, action]), terminarás con miles de issues duplicados. Las aplicaciones de alto tráfico también deberían implementar muestreo para no agotar las cuotas.

Evitando Entradas de Log Duplicadas

El LogSubscriber integrado de Rails ya escucha estos eventos CSRF y registra advertencias. Si construyes tu propio suscriptor, obtendrás entradas duplicadas—una de Rails, otra de la tuya.

Para deshabilitar el logging CSRF por defecto de Rails:

# config/application.rb
config.action_controller.log_warning_on_csrf_failure = false

Esto solo elimina la salida de log integrada—tus suscriptores personalizados siguen recibiendo todo. Las notificaciones se disparan independientemente.

Deshabilítalo cuando manejes el logging tú mismo, reenvíes a servicios externos, o estés cansado de los eventos ruidosos de fallback de clientes API y navegadores antiguos.

Entendiendo los Eventos

Token Fallback

El evento csrf_token_fallback se dispara cuando un navegador no envía el header Sec-Fetch-Site. Los navegadores modernos envían este header automáticamente, así que este evento típicamente significa:

  • Navegador antiguo (pre-2020)
  • Cliente API o script (curl, Postman, etc.)
  • Navegador con extensiones de privacidad que eliminan headers

Esto no es necesariamente un ataque—es informativo. Pero si ves esto desde lo que debería ser un navegador moderno, investiga.

Solicitud Bloqueada

El evento csrf_request_blocked es el importante. Se dispara cuando:

  • El token CSRF falta completamente
  • El token CSRF es inválido o expiró
  • El header Origin no coincide con los orígenes permitidos
  • Sec-Fetch-Site indica cross-site y el origen no es confiable

Causas legítimas incluyen sesiones expiradas, envíos dobles de formularios, o CORS mal configurado. Causas ilegítimas son ataques CSRF reales.

JavaScript Bloqueado

El evento csrf_javascript_blocked se dispara cuando alguien intenta incluir tu JavaScript desde otro origen. Esto es casi siempre sospechoso—ya sea un ataque o un CDN mal configurado.

Silenciando Eventos Ruidosos

Algunos endpoints disparan eventos CSRF durante operación normal—aplicaciones móviles refrescando tokens, formularios JavaScript con condiciones de carrera, integraciones legacy. Puedes filtrarlos:

class CsrfMonitor < ActiveSupport::Subscriber
  attach_to :action_controller

  IGNORED_ENDPOINTS = [
    ["SessionsController", "refresh"],     # Refresh de token de app móvil
    ["Api::V1::SyncController", nil],      # nil coincide con cualquier acción
    ["LegacyIntegrationController", "callback"]
  ].freeze

  def csrf_request_blocked(event)
    return if ignored_endpoint?(event.payload)
    # ... manejar evento
  end

  private

  def ignored_endpoint?(payload)
    IGNORED_ENDPOINTS.any? do |controller, action|
      payload[:controller] == controller &&
        (action.nil? || payload[:action] == action)
    end
  end
end

Probando Tu Suscriptor

# 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

Disparando Eventos en Desarrollo

Dispara eventos manualmente desde la consola de Rails para verificar que tu suscriptor funciona:

# Simular una solicitud CSRF bloqueada
ActiveSupport::Notifications.instrument(
  "csrf_request_blocked.action_controller",
  controller: "TestController",
  action: "create",
  sec_fetch_site: "cross-site",
  message: "CSRF token verification failed"
)

# Simular un token fallback
ActiveSupport::Notifications.instrument(
  "csrf_token_fallback.action_controller",
  controller: "TestController",
  action: "update",
  sec_fetch_site: nil
)

O dispara un fallo CSRF real enviando un formulario sin un token válido:

curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "user[name]=test"

Conclusión

Esto se incluye en Rails 8.2. Si lo quieres ahora, apunta tu Gemfile a main. PR #56355 tiene los detalles.