296 lines
13 KiB
Markdown
296 lines
13 KiB
Markdown
# 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.
|