Protegiendo APIs de marketing con un sistema multidimensional de limitación de tasa
Las actividades de marketing en mini-programas son objetivos principales para ataques automatizados: distribución de cupones, ventas flash, sorteos y programas de referidos, cada uno representa beneficios económicos directos. Los actores maliciosos utilizan scripts y plataformas de números temporales para reclamar cupones masivamente. Recientemente, nuestro sistema sufrió un ataque durante una promoción de cupones de descuento, donde la interfaz de reclamación fue explotada para agotar en dos minutos la cantidad de cupones programada para tres días, impidiendo que los usuarios legítimos recibieran ninguno. Como resultado, rediseñamos nuestro sistema de limitación de una configuración unidimensional simple a un enfoque multidimensional que considera usuario, dispositivo, IP y endpoint. Este artículo documenta el diseño completo y cinco problemas críticos que enfrentamos durante la implementación.
1. Entendiendo los objetivos de la limitación de tasa
La limitación de tasa no existe simplemente para mostrar errores elegantes, sino para proteger tres niveles distintos:
| Nivel | Objeto protegido | Manejo correcto cuando se excede |
|---|---|---|
| Recursos | Pools de conexiones de BD, pools de hilos, servicios downstream | Fallo rápido, retornar directamente "sistema sobrecargado" |
| Negocio | Inventario de cupones, premios, presupuesto de marketing | Indicar amablemente "agotado", guiar a otras actividades |
| Seguridad | Listas negras, cuentas anómalas, números temporales | Interceptar silenciosamente o solicitar verificación humana |
Combinar estos tres niveles en un solo interceptor es un error común: cuando se excede el nivel de recursos, necesitamos un fallo rápido sin consultas adicionales; cuando se excede el nivel de negocio, mostramos un mensaje apropiado; y para el nivel de seguridad, incluso podemos implementar "éxito falso" (retornar éxito sin otorgar el beneficio para evitar que los atacantes detecten las reglas).
2. Selección de algoritmos: ventanas fijas, deslizantes y cubos de tokens
Los tres algoritmos no son mutuamente excluyentes, sino que tienen sus propósitos específicos:
- Contador de ventana fija: Implementación más simple con
INCR + EXPIRE, pero sufre del problema del doble de tráfico en los bordes de la ventana. Adecuado solo para protección gruesa (como solicitudes totales por IP por minuto). - Ventana deslizante (enfoque ZSET): Utiliza ZSET para almacenar timestamps, limpia registros fuera de la ventana con
ZREMRANGEBYSCORE. Es preciso pero consume más memoria, ideal para conteos de bajo volumen en dimensión de usuario. - Cubo de tokens: Permite cierta ráfaga de tráfico, adecuado para modelar el tráfico general a nivel de gateway, permitiendo que las solicitudes uniformes no sean bloqueadas incorrectamente.
Nuestra distribución final fue: cubo de tokens a nivel de gateway para modelar el volumen total, ventana deslizante con Redis+Lua para dimensiones de usuario/dispositivo, y ventana fija solo para dimensiones de alta cardinalidad como IP.
3. Implementación central: ventana deslizante con Redis+Lua
La ventana deslizante debe usar Lua para garantizar la atomicidad de las operaciones "limpieza+conteo+escritura", de lo contrario el conteo será incorrecto bajo alta concurrencia:
-- KEYS[1] = clave de limitación
-- ARGV[1] = tamaño de ventana(ms) ARGV[2] = máximo permitido ARGV[3] = timestamp actual ARGV[4] = ID único de solicitud
local clave = KEYS[1]
local ventana = tonumber(ARGV[1])
local limite = tonumber(ARGV[2])
local ahora = tonumber(ARGV[3])
local idSolicitud = ARGV[4]
-- 1. Eliminar registros fuera de la ventana
redis.call('ZREMRANGEBYSCORE', clave, 0, ahora - ventana)
-- 2. Contar solicitudes en ventana actual
local conteo = redis.call('ZCARD', clave)
if conteo >= limite then
return 0
end
-- 3. Registrar esta solicitud, usando timestamp como score
redis.call('ZADD', clave, ahora, idSolicitud)
-- Establecer expiración solo en la primera escritura
if conteo == 0 then
redis.call('PEXPIRE', clave, ventana + 1000)
end
return 1
En el lado de Java, utilizamos una anotación + interceptor para centralizar la lógica:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LimitadorTasa {
String escenario(); // escenario: cupon_oferta / venta_flash / sorteo
int maximo() default 1; // máximo permitido en ventana
long ventanaMs() default 1000; // tamaño de ventana
DimensionLimitacion[] dimensiones(); // combinación de dimensiones
}
enum DimensionLimitacion { ID_USUARIO, ID_DISPOSITIVO, IP, ID_ACTIVIDAD }
Una solicitud de cupón genera múltiples claves como combinación cartesiana de dimensiones, por ejemplo:
lt:cupon_oferta:uid:10086
lt:cupon_oferta:did:a1b2c3...
lt:cupon_oferta:ip:1.2.3.4
lt:cupon_oferta:aid:2026verano (el inventario total se maneja con otra lógica)
Si cualquier dimensión retorna 0, la solicitud es bloqueada. Los umbrales varían significativamente entre dimensiones: la dimensión de usuario podría ser "1 vez cada 10 segundos, 3 veces por día", la dimensión de dispositivo "10 veces por día", y la dimensión IP debe ser más permisiva (redes de oficinas y residencias usan NAT, con mayor riesgo de falsos positivos).
4. Cinco problemas críticos encontrados
Problema 1: El miembro de ZSET no puede ser el timestamp directamente
Inicialmente usábamos el timestamp como miembro, pero dos solicitudes en el mismo milisegundo con ZADD se sobreescribían mutuamente, resultando en conteos menores y limitación inefectiva. El miembro debe ser un ID único de solicitud (ID de nieve o UUID), usando el timestamp solo como score.
Problema 2: PEXPIRE ejecutado en cada solicitud mantiene las claves indefinidamente
Los elementos ZSET no expiran automáticamente, dependiendo de la limpieza de ventana. Pero si ejecutamos PEXPIRE en cada solicitud, una clave que apareció solo una vez en el borde de la ventana permanecerá indefinidamente. La lógica de expiración debe ejecutarse solo cuando ZCARD == 0 en la primera escritura, haciendo que el ciclo de vida de la clave siga estrictamente la ventana. Sin esta corrección, Redis acumulará millones de claves vacías después de cada actividad.
Problema 3: Limitar solo por dimensión de usuario es fácilmente evadido
Los actores maliciosos poseen miles de números de teléfono verificados, cada "usuario" reclama solo una vez, haciendo que los umbrales por usuario sean ineficaces. Las dimensiones de dispositivo e IP deben implementarse simultáneamente, y se debe puntuar específicamente el comportamiento de "múltiples cuentas cambiando rápidamente en el mismo dispositivo". Además, en el ecosistema de WeChat debemos aprovechar la estabilidad del openid, mientras que en aplicaciones móviles necesitamos huellas de dispositivo en lugar de IDs simples (que pueden ser manipulados con herramientas de modificación).
Problema 4: Cuando Redis no está disponible, la limitación rechaza todo el tráfico
¿Qué pasa cuando el componente de limitación falla? Cuando redis.call excede tiempo o no puede conectarse, el interceptor debe tener una estrategia de degradación explícita: para interfaces sensibles (emisión de cupones, retiros) elegimos fail-close (rechazar solicitudes, mejor prevenir que lamentar); para interfaces de visualización ordinarias usamos fail-open (permitir, la limitación no debe ser un punto único de fallo). Esta estrategia debe configurarse explícitamente por interfaz. Además, el script Lua debe cargarse con SCRIPT LOAD al inicio y ejecutarse con EVALSHA, evitando transmitir el script completo en cada solicitud.
Problema 5: Retornar 429 con mensajes inadecuados convierte a usuarios en atacantes
Si el backend limita correctamente pero el frontend reintenta automáticamente indefinidamente, esencialmente estamos causando un DDoS a nosotros mismos. El enfoque correcto es: retornar Retry-After en los encabezados, implementar reintentos con retroceso exponencial máximo 3 veces; mostrar mensajes claros como "agotado, vuelve mañana" en la capa de negocio; y para la capa de seguridad, retornar una estructura casi idéntica al éxito, omitiendo solo el registro del beneficio.
5. Pruebas de estrés y validación previas al despliegue
- Pruebas unidimensionales: Construir solicitudes de alta frecuencia del mismo usuario, verificando que a partir de la N+1 se bloquee y que el ZSET en Redis contenga exactamente el número de elementos del umbral.
- Pruebas de borde: Enviar tráfico en ambos lados del límite de la ventana, verificando que la ventana deslizante no tenga la vulnerabilidad del doble pico de las ventanas fijas.
- Inyección de fallos en Redis: Usar firewalls para desconectar y verificar que las estrategias fail-close/fail-open funcionen por interfaz.
- Monitoreo de falsos positivos: Después del despliegue, monitorear específicamente el porcentaje de bloqueos por dimensión IP. Los falsos positivos en NAT se concentrarán en redes corporativas y universitarias, requiriendo ajustes rápidos de umbrales o implementación de verificación humana.
6. Dos detalles de ingeniería adicionales: gestión de cardinalidad y reposición de cuotas
6.1 El problema de explosión de cardinalidad
La cantidad de claves en limitación multidimensional es usuarios × dispositivos × escenarios, resultando en potencialmente millones de claves después de una actividad. Además de "establecer expiración solo en la primera escritura", implementamos dos medidas: primero, reutilizamos prefijos de clave para dimensiones de usuario y dispositivo, limpiando residuos después de la actividad con recorrido con cursor (SCAN, nunca KEYS); segundo, validamos huellas de dispositivo anormalmente largas con verificaciones de longitud y conjunto de caracteres, bloqueando claves inválidas. Una vez un colega ejecutó KEYS lt:* en producción, bloqueando una instancia con 20 millones de claves durante más de 10 segundos: una lección dolorosa—todos los scripts de mantenimiento deben usar exclusivamente SCAN/HSCAN.
6.2 Reposición de cuotas después de limitación incorrecta
La limitación inevitablemente afecta usuarios legítimos: reintentos de red o clics múltiples pueden consumir cuota. Nuestro enfoque es reponer el conteo en escenarios de "éxito de negocio pero detectado como duplicado"—por ejemplo, solicitudes repetidas de cupón con la misma clave de idempotencia donde el cupón no fue emitido (la solicitud anterior falló y revirtió). El script Lua debe sincronizar la reposición con la deducción en el mismo script para evitar inconsistencias. Adicionalmente, para acciones de alta confianza como pago exitoso o verificación de identidad, podemos agregar una pequeña bonificación dinámica a la cuota del usuario, permitiendo que los usuarios regulares casi nunca encuentren límites, mientras que cuentas nuevas o anómalas son tratadas con restricción—combinar estrategias de limitación con segmentación de usuarios reduce significativamente los falsos positivos.
Conclusión
La protección contra actividades fraudulentas en marketing no es simplemente configurar números, sino implementar un sistema en capas: capa de recursos con fallo rápido, capa de negocio con degradación amigable, capa de seguridad con manejo silencioso; algoritmos con cubo de tokens en gateway para modelado, ventana deslizante para control preciso por usuario, y ventana fija para protección gruesa por IP; dimensiones cruzando usuario, dispositivo, IP y actividad, donde cualquier anomalía en una dimensión puede activar el bloqueo. Finalmente, recordar que el propio componente de limitación puede fallar, y definir estrategias de degradación según la sensibilidad financiera de cada interfaz, es lo que constituye una solución verdaderamente lista para producción.