No empezamos queriendo construir un sistema de detección de bots. Empezó porque necesitábamos entender qué estaba pasando en las rutas del backend.
Teníamos aplicaciones Django en producción con tráfico creciente, y junto con los usuarios reales llegaban solicitudes que no tenían sentido: escaneos de paths que no existen, sesiones que cambiaban de user-agent a mitad de camino, picos de requests del mismo IP a intervalos demasiado perfectos. Nada de esto generaba errores. Todo pasaba silenciosamente.
Lo que faltaba era una forma de mirar el comportamiento de cada sesión en las rutas y decir: "esto no parece un usuario real".
Por qué el rate limiting no alcanzaba
Rate limiting fue lo primero que probamos. Funciona para el caso obvio — la IP que manda 200 requests por minuto. Pero los casos que nos preocupaban eran otros.
Un bot que hace 25 requests por minuto con intervalos regulares, sin cookie de sesión, accediendo a endpoints autenticados con un user-agent de Chrome 110 (lanzado a principios de 2023) no va a caer en ningún rate limiter razonable. Parece tráfico normal si solo mirás el volumen.
El problema es que el rate limiting toma decisiones con base en un número. Necesitábamos tomar decisiones con base en el comportamiento — qué está haciendo la sesión, cómo lo está haciendo, y si ese patrón tiene sentido para un humano.
Análisis de riesgo por comportamiento en las rutas
La idea central es simple: cada solicitud que llega al backend carga señales sobre quién (o qué) está del otro lado. Ninguna señal sola prueba nada, pero la combinación cuenta una historia.
Una sesión que accede a /api/auth/login 40 veces en 3 minutos, con intervalos de exactamente 1.2 segundos entre cada intento, sin haber accedido nunca a la página de login en el frontend, se está comportando de una forma en que ningún usuario real se comporta.
En lugar de crear reglas del tipo "si X entonces bloquea", armamos un sistema de score de riesgo: cada señal aporta puntos, y el bloqueo ocurre cuando el acumulado supera un umbral. Esto evita falsos positivos de señales aisladas y aún así atrapa a los bots que serían invisibles para cualquier regla individual.
Cómo funciona en la práctica
Lo que no podía pasar era que este análisis agregara latencia perceptible. Las rutas del backend necesitan responder rápido — el análisis de riesgo tiene que ser casi invisible en el tiempo de respuesta.
El middleware hace todo dentro del ciclo de la solicitud, en dos etapas:
Antes de la vista: consulta Redis para ver si esa IP ya está bloqueada. Si existe la clave rg:blocked:{ip}, devuelve 429 inmediatamente — sin correr analyzers, sin tocar la vista. Es un lookup en Redis que toma menos de 1ms.
Después de la vista: la solicitud pasó, la vista se ejecutó, y ahora el middleware registra lo que pasó en el historial deslizante (path, método, status code, duración, user-agent). Con el historial actualizado, los analyzers corren y calculan el score. Si supera el umbral de bloqueo (por defecto: 80), la IP se marca en Redis con TTL de 1 hora y las siguientes solicitudes se cortan antes de llegar a la vista.
Hay un detalle importante: los analyzers evalúan el historial de solicitudes anteriores, no la solicitud actual. Esto significa que la primera solicitud de una IP desconocida siempre pasa. El bloqueo ocurre cuando el patrón se acumula — lo que en la práctica toma pocos segundos para un bot agresivo.
Además del bloqueo, existe un umbral intermedio de challenge (por defecto: 50). Cuando el score queda entre 50 y 80, la solicitud no se bloquea, pero el middleware setea request.risk.challenged = True. La vista puede usar esto para decidir qué hacer — exigir CAPTCHA, limitar funcionalidad, o solo registrar.
Los logs estructurados van a Grafana vía Loki, donde acompañamos scores, bloqueos y señales en tiempo real.
Los analyzers
Cada analyzer mira un aspecto diferente del comportamiento en las rutas. Son seis en total — cinco que corren en el middleware en cada solicitud HTTP y uno que corre en el login.
RateAnalyzer — calcula requests por minuto en la ventana deslizante de 5 minutos. Tres franjas: arriba de 30 RPM suma +15, arriba de 60 suma +30, arriba de 120 suma +50. Si todas las solicitudes caen en el mismo milisegundo (span cero), es +50 directo.
UserAgentAnalyzer — user-agent ausente da +30. Herramientas conocidas de automatización (curl, scrapy, python-requests, wget, go-http-client) dan +40. Versión de Chrome por debajo de 120 da +20. Extrae la versión con regex, así que no depende de una lista externa.
SessionAnalyzer — rastrea sesiones por IP usando Sets en Redis. Más de 10 sesiones distintas del mismo IP en 5 minutos da +30. Más de 3 user-agents diferentes dentro de la misma sesión da +35. Acceso a rutas con prefijo /api/ o /admin/ sin sesión autenticada da +25.
PatternAnalyzer — mantiene una lista de paths de escaneo: /.env, /wp-admin, /phpmyadmin, /.git, /.aws, /config.php. Cualquier hit da +60 — es el analyzer que más puntúa en una sola señal, porque acceder a /.env en una aplicación Django no tiene explicación inocente. También monitorea tasa de errores (más de 50% de status 4xx da +30) y diversidad de paths (más de 40 paths distintos en la ventana da +25).
TimingAnalyzer — calcula el coeficiente de variación de los intervalos entre solicitudes. Necesita al menos 5 solicitudes para activarse. Si el CV es menor a 0.05 (intervalos casi idénticos), suma +30. Es el analyzer más sutil — atrapa bots que controlan el volumen pero no randomizan el timing.
EmailAnalyzer — este no corre en el middleware. Es activado por los signals user_logged_in y user_login_failed de Django. Analiza el email usado en el login: dominio descartable (mailinator, guerrillamail, tempmail) da +40, sufijo hexadecimal largo da +30, proporción alta de dígitos da +25, entropía de Shannon arriba de 3.5 da +30. Útil para detectar registros automatizados.
Uso en el signup: fricción proporcional al riesgo
Uno de los usos que más nos gusta es en el flujo de registro. La idea es simple: usuario de bajo riesgo sigue directo, usuario de alto riesgo necesita probar que es real antes de usar la cuenta.
En la práctica, cuando alguien envía el formulario de signup, corremos el EmailAnalyzer contra el email informado. Si el score queda bajo — email de dominio corporativo, entropía normal, sin patrón de generación automática — la cuenta se crea activa y el usuario sigue al onboarding sin ninguna fricción extra.
Si el score queda alto — email de dominio descartable, local part con cara de hash generado, proporción alta de dígitos — la cuenta se crea pero queda inactiva. El usuario recibe un email de verificación y solo puede acceder después de confirmar. Dependiendo del contexto, agregamos un CAPTCHA en el propio formulario de signup cuando el middleware ya detectó que la sesión tiene score de challenge (arriba de 50) antes de que llegue al submit.
El punto es que la decisión no es binaria. No es "todos confirman email" (que agrega fricción innecesaria para usuarios legítimos) ni "nadie confirma" (que deja la puerta abierta para registros masivos). Es fricción proporcional al riesgo.
En la práctica esto mató la mayoría de los registros automatizados que teníamos. Bots de signup masivo típicamente usan dominios descartables, emails con entropía alta, y hacen decenas de registros del mismo IP en minutos. El combo del EmailAnalyzer con el SessionAnalyzer atrapa esto en los primeros intentos — y el management command audit_emails permite escanear retroactivamente los registros que pasaron antes de que el sistema entrara en producción.
Un caso real: score acumulándose hasta el bloqueo
Para ser más concretos, acá va un caso que atrapamos en producción. Los valores están simplificados, pero el patrón es real.
Un martes a la mañana, una IP empieza a acceder a la API de autenticación de una de nuestras aplicaciones. Las primeras solicitudes no llaman la atención — volumen bajo, user-agent de Chrome reciente.
Pero el patrón se va revelando:
| Momento | Qué pasó | Analyzer | Score acumulado |
|---|---|---|---|
| #1–#5 | POST /api/auth/login cada 1.3s, sin sesión autenticada | SessionAnalyzer (auth sin sesión: +25) + TimingAnalyzer (CV < 0.05: +30) | 55 |
| #6 | Misma IP, user-agent cambia de Chrome a Firefox | SessionAnalyzer (rotación de UA: +35, cap en 100) | 80 |
| #7 | Score = 80. Umbral de bloqueo: 80. IP marcada en Redis. | — | bloqueada |
| #8+ | Todas las solicitudes siguientes | Middleware devuelve 429 directo de Redis | — |
Del primer request al bloqueo: 8 segundos y 6 solicitudes. A partir de la #7, el middleware consulta Redis, encuentra rg:blocked:{ip}, y devuelve 429 Too Many Requests sin correr ningún analyzer y sin que la solicitud llegue a la vista.
Lo que llama la atención es que entre la #5 y la #6, el score ya estaba en 55 — arriba del umbral de challenge (50), pero abajo del de bloqueo (80). Si la vista hubiera usado request.risk.challenged, podría haber exigido CAPTCHA en ese punto. Cuando el user-agent cambió en la #6, el SessionAnalyzer sumó +35 por rotación de UA y el score llegó a 80.
Ninguna señal sola habría bloqueado. Cinco intentos de login sin sesión? Da +25 — lejos del bloqueo. Intervalos regulares? +30 — todavía no alcanza. Pero la combinación de los tres, en 8 segundos, no deja dudas.
Falsos positivos y los casos que no son obvios
El modelo de score funciona bien para bots, pero trae una preocupación inevitable: ¿y cuando bloquea a quien no debería?
Mapeamos los escenarios que más generan falsos positivos y cómo tratamos cada uno:
VPN corporativa y NAT — 50 usuarios reales detrás de la misma IP. El SessionAnalyzer ve decenas de sesiones nuevas del mismo IP y suma +30 (excessive_sessions). En proyectos donde esto es común, subimos el umbral de bloqueo o escribimos un analyzer customizado que pondera por sesión autenticada — si las sesiones tienen tokens válidos, el peso de la IP sube más lento. El paquete no resuelve esto solo porque depende de cómo cada aplicación gestiona la autenticación.
Health checks y monitoreo — un cliente corriendo requests contra /health/ cada 30 segundos con intervalos perfectos. El TimingAnalyzer puntuaba alto. La solución ya viene en el paquete: IGNORE_PATHS incluye /health/, /metrics/ y /__debug__/ por defecto. Solicitudes a estos paths no pasan por los analyzers y no graban historial. Si el health check apunta a otra ruta, solo hay que agregarla a la lista.
Usuarios con browsers antiguos — Chrome por debajo de la versión 120 da +20 en el UserAgentAnalyzer. No bloquea solo (umbral es 80), pero suma. En organismos públicos donde la TI controla la versión instalada, esto es común. El min_chrome_version es configurable — se puede ajustar al perfil de usuario de cada proyecto.
Bots legítimos — Googlebot, Bingbot, herramientas de SEO. El UserAgentAnalyzer los marca como bot (+40). El paquete no trae whitelist de IP integrada, entonces lo tratamos en una capa superior — vía signal handler que escucha risk_assessed y pone el score en cero si la IP coincide con los rangos de Google vía DNS reverso. Quien hace spoofing de Googlebot no pasa.
La calibración de los primeros días es la parte más importante. Subimos el umbral a un valor inalcanzable (tipo 200) y activamos LOG_ALL_SCORES para registrar todo score, incluso cero. Lo corremos así una o dos semanas solo observando el perfil de tráfico real. Cuando activamos el bloqueo de hecho, empezamos con el default de 80 y ajustamos según el volumen de falsos positivos.
El paquete open source
Cuando nos dimos cuenta de que esta estructura de análisis era lo suficientemente genérica para funcionar en cualquier proyecto Django, la extrajimos en un paquete separado.
django-risk-guardian está en GitHub con licencia MIT. Agregás el middleware, configurás el cache backend para Redis, y los 6 analyzers corren con defaults funcionales.
Además del middleware, el paquete incluye algunas piezas que facilitan la integración:
- Signals —
ip_blocked,risk_assessedychallenge_required. Se puede conectar cualquier lógica customizada (notificación en Slack, whitelist dinámica, log externo) sin tocar el middleware. - Decorators —
@require_risk_below(threshold=50)y@require_no_challengepara proteger vistas específicas. Devuelven 429 si el score no cumple. - Management command —
python manage.py audit_emailsrecorre la base de usuarios y corre el EmailAnalyzer retroactivamente. Útil para identificar registros automatizados que ya pasaron. request.risk— toda solicitud recibe un objetoRiskAssessmentconscore,reasons,blocked,challengedy acceso alhistory. La vista puede tomar decisiones granulares con base en esto.
Lo que no está en el paquete son los umbrales específicos que usamos en cada proyecto y los analyzers customizados que escribimos para dominios específicos. Cada aplicación tiene un perfil de uso diferente, y los límites necesitan reflejar eso. Es el mismo modelo de fail2ban: motor público, configuración operacional privada.
👉 github.com/mupisystems/django-risk-guardian
Qué cambia en el día a día
Antes sabíamos que había tráfico sospechoso, pero no teníamos forma de cuantificarlo ni de reaccionar en tiempo real. Ahora, en Grafana, podemos ver qué IPs están acumulando score, por cuáles señales, y en qué momento fueron bloqueadas.
Nginx sigue haciendo su trabajo en el borde. Django ahora sabe no solo qué pidió cada solicitud, sino cuánto ese patrón de acceso se parece a comportamiento legítimo. Y cuando no se parece, bloquea antes de llegar a la vista.
No es una solución que resuelve todo. Pero cierra una brecha que existía entre el rate limiting genérico y el "esperemos que nadie abuse".
¿Querés implementar análisis de riesgo comportamental en tu backend Django? Hablá con nosotros.
Etiquetas
Sobre el Autor
admin
Especialista em transformação digital
Categorías
¿Te gustó este contenido?
Suscríbete a nuestro boletín y recibe más información como esta directamente en tu correo electrónico.
Hablar con especialistas