# 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.
