# 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 ```typescript // 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: ```typescript // 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: ```typescript 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: ```bash # Ejecutar desde terminal (requiere token válido) TOKEN= 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 ```bash # Con admin global logueado → NO debería bloquearse TOKEN= 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= 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.