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 headerSec-Fetch-Site, recurriendo a la verificación por tokencsrf_request_blocked.action_controller— La solicitud fue bloqueada debido a un fallo en la validación CSRFcsrf_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-Siteindica 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.