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

296 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<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
```bash
# 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.