13 KiB
Expansión de rate limiting más allá de auth
Estado: ✅ IMPLEMENTADO (2026-04-14) — middleware rate-limit.middleware.ts con 4 tiers aplicados a endpoints costosos. Admin global (superset platform_admin/platform_ti) exento. Ver sección final "Implementación ejecutada".
Problema
Hoy express-rate-limit está configurado solo en rutas de autenticación:
/auth/login: 10 intentos / 15 min/auth/register: 3 / hora
El resto del API acepta cualquier volumen de requests de usuarios autenticados. Vectores posibles:
- Usuario malicioso autenticado exporta CFDIs en loop (endpoint
/cfdicon filtros, genera carga pesada en BD) - Script automatizado bombardea
/dashboardpara alguna razón (scraping, prueba de carga no autorizada) - Cliente competidor con login legítimo intenta mapear la BD haciendo queries masivas
- Cron accidentalmente mal configurado por un integrador golpea
/api/cfdi/sync-manualcada segundo
A nivel de operatividad no es un ataque catastrófico (el API no se cae), pero:
- Degrada performance para otros usuarios
- Cuesta $ en cómputo
- En caso extremo, satura Facturapi / MP quota
Propuesta
Aplicar rate limiting por-endpoint con tiers distintos según sensibilidad / costo computacional.
Tiers propuestos
| Tier | Rate | Uso |
|---|---|---|
| strict | 10 req / hora | Operaciones costosas o sensibles (SAT sync manual, export bulk, emisión facturas) |
| normal | 100 req / 15 min | APIs de negocio típicas (dashboard, cfdi list, reportes) |
| relaxed | 500 req / 15 min | Endpoints de lectura simple (catálogos, metadata) |
| auth | ya existe | login/register (10 y 3) |
Aplicación por route
// apps/api/src/middlewares/rate-limit.middleware.ts
import rateLimit from 'express-rate-limit';
export const strictLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hora
max: 10,
keyGenerator: (req) => req.user?.userId || req.ip, // Por user autenticado, no por IP
message: { error: 'Demasiadas solicitudes. Intenta de nuevo en una hora.' },
standardHeaders: true,
legacyHeaders: false,
});
export const normalLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
keyGenerator: (req) => req.user?.userId || req.ip,
});
export const relaxedLimit = rateLimit({
windowMs: 15 * 60 * 1000,
max: 500,
keyGenerator: (req) => req.user?.userId || req.ip,
});
Clave importante: keyGenerator basado en userId, no IP. Usuarios legítimos detrás de NAT (ej: oficina con 50 contadores) compartirían IP y se bloquearían entre sí.
Endpoints críticos para strictLimit
| Endpoint | Razón |
|---|---|
POST /sat/sync/manual |
Syncs tardan minutos, son pesados |
POST /sat/sync/custom-range |
Ídem con rango personalizado |
POST /facturacion/emitir |
Cada emisión cuesta un timbre (dinero) |
POST /facturacion/cancelar |
Facturapi API + SAT interaction |
POST /cfdi/bulk (carga masiva) |
Procesa hasta 50MB por request |
POST /documentos/opiniones/consultar |
Lanza Playwright (cómputo pesado) |
GET /cfdi/export (si existe) |
Excel generation puede ser costoso |
POST /auth/password-change (futuro) |
Prevenir brute force de password actual |
POST /subscriptions/me/upgrade |
Crea MP preference (side effect en tercero) |
Endpoints para normalLimit
| Endpoint | Razón |
|---|---|
GET /dashboard |
Query pesada (aggregations) |
GET /cfdi |
List con filtros |
GET /reportes/* |
Reportes custom |
GET /impuestos/* |
Cálculos fiscales |
POST /subscriptions/me/subscribe |
Creates preapproval |
POST /subscriptions/me/cancel |
|
| Default para todos los demás autenticados |
Endpoints para relaxedLimit
| Endpoint | Razón |
|---|---|
GET /catalogos/* (claves SAT, unidades, etc.) |
Solo lectura, datos pequeños |
GET /regimenes |
Lista fija |
GET /subscriptions/plans |
Solo 8 filas |
Headers de respuesta
Los headers RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset se envían automáticamente con standardHeaders: true. El frontend puede leerlos y mostrar warnings preventivos antes del hard block.
Error handling en frontend
Cuando el backend devuelve 429:
// apps/web/lib/api/client.ts
if (error.response?.status === 429) {
toast.error('Demasiadas solicitudes. Espera unos minutos e intenta de nuevo.');
}
Si es una acción crítica (emisión factura, pago), mostrar mensaje más específico.
Whitelist para admin global
El admin global probablemente necesita bypassar los límites para operaciones administrativas masivas. Opción:
const strictLimit = rateLimit({
...,
skip: async (req) => {
if (!req.user) return false;
return await isGlobalAdmin(req.user.tenantId, req.user.role);
},
});
Trade-off: si la cuenta admin global se compromete, sin rate limit. Acceptable dado que el admin global tiene poder total de todas formas.
Alcance
| Tarea | Estimación |
|---|---|
Crear middleware rate-limit.middleware.ts con 3 tiers |
1 h |
| Aplicar middleware a endpoints críticos (~15) | 2 h |
| Frontend toast handler para 429 | 30 min |
| Tests manuales (curl loop verificando block) | 1 h |
| Docs de límites en API reference | 30 min |
| Total | ~medio día |
Riesgos
- Falsos positivos en dev/testing. Al correr tests automatizados con sesión real, se hittean los límites. Solución: modo dev con límites muy permisivos via env var, o usar el skip.
- Contadores de límite viven en memoria por worker. Con PM2 cluster mode, un usuario distribuye entre N workers y efectivamente tiene N× el límite. Para MVP aceptable; si crece, migrar a Redis store (
rate-limit-redis). - Rate limit bypassable si se rota tenantId con X-View-Tenant. El admin global puede hacerlo pero ya está en whitelist. Otros roles no deberían tener esa capacidad.
- Si se integra IA (Lolita) con endpoints del API, puede necesitar tier propio. Revisar cuando se implemente.
Testing
Script simple de verificación:
# Ejecutar desde terminal (requiere token válido)
TOKEN=<jwt>
for i in {1..15}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync/manual -X POST
done
# Esperado: primeros 10 → 200/403, #11+ → 429
Archivos a tocar
apps/api/src/middlewares/rate-limit.middleware.ts— nuevoapps/api/src/routes/*.routes.ts— aplicar middleware en rutas elegidasapps/web/lib/api/client.ts— handler 429docs/architecture/api-reference.md— documentar límites
Relación con otros planes
2026-04-14-audit-log.md: rate limits pueden dispararse por abuso; audit de 429 recurrentes indica potencial atacante.2026-04-14-jwt-revocation.md: si rate limit detecta patrón de brute force, puede disparar tokenVersion invalidación.
Implementación ejecutada (2026-04-14)
Decisión de tiers (vs plan original)
El plan proponía 3 tiers (strict/normal/relaxed). Durante la ejecución se agregó un 4º tier más restrictivo (veryStrict: 2/día) para operaciones extremadamente costosas que disparan jobs largos con terceros:
| Tier | Rate | Uso |
|---|---|---|
| veryStrict | 2 / 24h | SAT sync manual, opinión de cumplimiento |
| strict | 10 / 1h | Emisión/cancelación factura, CFDI bulk, subs (subscribe/change/upgrade), password-change |
| normal | 100 / 15m | Dashboard, reportes, impuestos |
| relaxed | 500 / 15m | Catálogos SAT, regímenes |
Razón del 4º tier: syncs del SAT y scraping de Opinión de Cumplimiento (Playwright) tardan minutos cada uno y golpean APIs externas. 10/hora era demasiado permisivo; 2/día cubre el uso legítimo (usuario refresca datos 1-2 veces al día) y bloquea loops accidentales o maliciosos de manera contundente.
Middleware (src/middlewares/rate-limit.middleware.ts)
- Todas las keys se generan por
req.user.userId(fallback areq.ippara anónimo). No por IP → oficinas con NAT compartido no se bloquean entre sí. skip: skipForGlobalAdminvíahasPlatformRole(userId, 'platform_admin')— retorna true para supersets (admin o TI). Otros platform roles (support/sales/finance) sí respetan rate limits.standardHeaders: true→ el cliente recibeRateLimit-Limit,RateLimit-Remaining,RateLimit-Reset.- Mensajes en español con duración específica del tier ("intenta mañana" para veryStrict, "intenta en una hora" para strict).
Dónde se aplica
Aplicado a rutas específicas (POST costosos):
POST /api/sat/sync → veryStrictLimit
POST /api/documentos/opiniones/consultar → veryStrictLimit
POST /api/facturacion/emitir → strictLimit
POST /api/facturacion/cancelar/:uuid → strictLimit
POST /api/cfdi/bulk → strictLimit
POST /api/subscriptions/me/subscribe → strictLimit
POST /api/subscriptions/me/change → strictLimit
POST /api/subscriptions/me/upgrade → strictLimit
POST /api/auth/password-change → strictLimit
Aplicado a routers completos (GET principalmente):
/api/dashboard → normalLimit
/api/reportes → normalLimit
/api/impuestos → normalLimit
/api/catalogos → relaxedLimit
/api/regimenes → relaxedLimit
Deliberadamente NO limitados (uso infrecuente o crítico no-abusable): /auth/login y /auth/register ya tenían sus propios limiters específicos; /cfdi GET, /bancos, /calendario, /alertas, /conciliacion, /usuarios, /tenants, /fiel, /webhooks, /audit-log, /platform-staff quedan sin tope explícito — se pueden agregar después si se observa abuso.
Frontend (apps/web/lib/api/client.ts)
Interceptor nuevo en el axios response handler:
- Detecta HTTP 429 antes del bloque 401 existente
- Preserva el
messagedel backend para que lostry/catchexistentes lo muestren (ya usanerr?.response?.data?.message) - Fallback:
console.warncon el mensaje si nadie maneja el error explícitamente - Flag
_rateLimitHandleden eloriginalRequestevita loggear dos veces si el caller re-throws
Se consideró agregar un toast global pero el proyecto no usa librería de toasts; agregar Sonner/react-hot-toast sólo para esto sería sobre-ingeniería. Los call sites críticos (emisión, pago, sync SAT) ya tienen sus propios alert/try-catch.
Behavior con admin global
Verificado: el usuario admin@horux360.com (platform_admin) pasa el skip y no es rate-limited. Backfill útil cuando necesite hacer operaciones masivas sin trabas (corrección manual, cargas de datos, etc.). Otros platform staff (support/sales/finance) sí respetan los límites.
Archivos tocados
Backend:
src/middlewares/rate-limit.middleware.ts(nuevo) — 4 tiers exportadossrc/routes/sat.routes.ts— veryStrictLimit en/syncsrc/routes/documentos.routes.ts— veryStrictLimit en/opiniones/consultarsrc/routes/facturacion.routes.ts— strictLimit en/emitiry/cancelarsrc/routes/cfdi.routes.ts— strictLimit en/bulksrc/routes/subscription.routes.ts— strictLimit en/me/{subscribe,change,upgrade}src/routes/auth.routes.ts— strictLimit en/password-changesrc/routes/dashboard.routes.ts— normalLimit (router.use)src/routes/reportes.routes.ts— normalLimit (router.use)src/routes/impuestos.routes.ts— normalLimit (router.use)src/routes/catalogos.routes.ts— relaxedLimit (router.use)src/routes/regimen.routes.ts— relaxedLimit (router.use)
Frontend:
lib/api/client.ts— interceptor 429
Verificación manual
# Con admin global logueado → NO debería bloquearse
TOKEN=<jwt del admin global>
for i in {1..5}; do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync -X POST
done
# Esperado: todas 200/400 (el skip exime al admin)
# Con user normal → debería bloquearse al 3er intento
TOKEN=<jwt de user sin roles de plataforma>
for i in {1..5}; do
curl -s -w "%{http_code}\n" \
-H "Authorization: Bearer $TOKEN" \
http://localhost:4000/api/sat/sync -X POST
done
# Esperado: 2 pasan, #3+ retornan 429 con message en español
Caveats conocidos
- Counters in-memory por worker. Con PM2 cluster mode, un user distribuye entre N workers → efectivamente N× el límite. Para tier veryStrict esto importa más (2 × N_workers = hasta 8-16 syncs/día en cluster de 4-8). Si importa, migrar a
rate-limit-redis. Por ahora MVP acepta la holgura. X-View-Tenantno bypassa rate limit. El admin global al impersonar sigue exento (por su propio userId superset); otros roles no pueden usar X-View-Tenant así que no hay vector de escape.- Dev/testing sin
.envespecial. Scripts de test automatizados que corran contra un usuario normal hittearán límites. Solución: usar el admin global (ya exento) o añadir env varDISABLE_RATE_LIMITS=trueen el futuro si se necesita.
Pendientes / futuro
- Migrar a Redis store cuando se escale a PM2 cluster con >2 workers o múltiples nodos.
- Aplicar limits a endpoints heredados sin tope (
/cfdiGET con filtros pesados, exports Excel) si se detecta abuso en audit log. - Tier específico para Lolita (agente IA) cuando se integre con endpoints del API — probablemente merece su propio limiter (más generoso, porque el agente consulta muchos endpoints en secuencia).
- UI visible de rate-limit hit (toast/banner) cuando se adopte una librería de toasts.