Files
HoruxDespachos/docs/plans/2026-04-14-rate-limiting-expansion.md
2026-04-27 22:09:36 -06:00

13 KiB
Raw Permalink Blame History

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 /cfdi con filtros, genera carga pesada en BD)
  • Script automatizado bombardea /dashboard para 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-manual cada 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

  1. 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.
  2. 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).
  3. 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.
  4. 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 — nuevo
  • apps/api/src/routes/*.routes.ts — aplicar middleware en rutas elegidas
  • apps/web/lib/api/client.ts — handler 429
  • docs/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 a req.ip para anónimo). No por IP → oficinas con NAT compartido no se bloquean entre sí.
  • skip: skipForGlobalAdmin vía hasPlatformRole(userId, 'platform_admin') — retorna true para supersets (admin o TI). Otros platform roles (support/sales/finance) sí respetan rate limits.
  • standardHeaders: true → el cliente recibe RateLimit-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 message del backend para que los try/catch existentes lo muestren (ya usan err?.response?.data?.message)
  • Fallback: console.warn con el mensaje si nadie maneja el error explícitamente
  • Flag _rateLimitHandled en el originalRequest evita 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 exportados
  • src/routes/sat.routes.ts — veryStrictLimit en /sync
  • src/routes/documentos.routes.ts — veryStrictLimit en /opiniones/consultar
  • src/routes/facturacion.routes.ts — strictLimit en /emitir y /cancelar
  • src/routes/cfdi.routes.ts — strictLimit en /bulk
  • src/routes/subscription.routes.ts — strictLimit en /me/{subscribe,change,upgrade}
  • src/routes/auth.routes.ts — strictLimit en /password-change
  • src/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

  1. 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.
  2. X-View-Tenant no 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.
  3. Dev/testing sin .env especial. 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 var DISABLE_RATE_LIMITS=true en el futuro si se necesita.

Pendientes / futuro

  1. Migrar a Redis store cuando se escale a PM2 cluster con >2 workers o múltiples nodos.
  2. Aplicar limits a endpoints heredados sin tope (/cfdi GET con filtros pesados, exports Excel) si se detecta abuso en audit log.
  3. 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).
  4. UI visible de rate-limit hit (toast/banner) cuando se adopte una librería de toasts.