# Pentest interno — NV Core (Sprint 4)

Auditoría de seguridad interna del backend (NestJS 11 + Prisma/PostgreSQL) y
web (Vite + React). Combinó **revisión estática** (3 auditores por dimensión:
authn/authz+tenancy, inyección/XSS/SSRF/ReDoS, secretos/config/webhooks) con
**pruebas dinámicas en vivo** contra la API corriendo sobre Postgres real, con
dos tenants aislados (Alice→`codigo-creativo`, Mallory→`pulso-naturaleza`) y un
usuario sin membresía.

Fecha: 2026-08-08 · Alcance: `apps/api`, `apps/web`, `packages/domain`.

## Resumen

- **9 hallazgos accionables corregidos** (2 HIGH, 3 MEDIUM, 4 LOW) + endurecimientos.
- **1 HIGH mitigado con secure-by-default** (land-grab de Owner en el registro), con flag de escape documentado.
- Amplias áreas **verificadas sanas** (ver «Confirmado sano»).
- Suite tras los fixes: **API 303 tests**, lint 0 errores; fixes verificados en vivo.

## Hallazgos y estado

| # | Sev | Hallazgo | Fix | Verificación |
|---|-----|----------|-----|--------------|
| 1 | **HIGH** | **Escalada de privilegios**: un Admin podía re-asignarse a sí mismo `Owner` (o degradar al Owner real) vía `POST /workspaces/:ws/members` (aceptaba cualquier rol y `upsert` sobreescribía la membresía existente). | `AuthService.addMember/removeMember` ahora reciben al actor: **solo un Owner** puede asignar/modificar/quitar el rol Owner. | En vivo: Admin→Owner **403**, Admin degrada Owner **403**, Owner→Owner **201**. +4 tests. |
| 2 | **HIGH** | **SSRF** vía `webhookUrl` de automatización: URL absoluta arbitraria → `fetch` del servidor (metadata cloud/hosts internos), filtrando además la `X-N8N-API-KEY`. | `n8n.client`: `isPrivateHostname`/`assertPublicWebhookUrl` bloquean loopback/privadas/link-local/metadata/CGNAT; la API key solo se envía al host n8n de confianza. | En vivo: `http://169.254.169.254/...` → **503 «host interno/privado no permitido»** (bloqueado antes del fetch); `https://hooks.example.com` → pasa el guard («fetch failed»). +7 tests. |
| 3 | **HIGH** | **Swagger/OpenAPI expuesto en producción** (`/api/docs` sin guard de entorno) → recon de toda la superficie. | `main.ts`: `SwaggerModule.setup` solo cuando `env !== "production"`. | Dev sigue exponiendo (intencional); guard por `env` (verificado en código). |
| 4 | **MEDIUM** | **CSV formula injection** en export (contactos/campañas): campos con `= + - @` se ejecutan al abrir en Excel/Sheets. | `common/csv.ts`: `neutralizeFormula` prefija `'` a campos con inicio peligroso. | En vivo: export de `=2+3`/`@EVIL` → `'=2+3`/`'@EVIL`. +2 tests. |
| 5 | **MEDIUM** | **Comparación no constante** del secret del webhook de **Telegram** (`!==`) → side-channel de timing. | `safeEqual` (crypto `timingSafeEqual`). | En vivo: fail-closed **403**. +tests de `safeEqual`. |
| 6 | **MEDIUM** | **Comparación no constante** del secret del **bridge n8n** (`!==`), rutas `@SkipThrottle` (adivinación ilimitada). | `safeEqual`. | En vivo: secret incorrecto → **403**. |
| 7 | **LOW** | **Rutas de IA sin throttle por ruta**: hasta 120 llamadas LLM/min por cliente (abuso de coste). | `@Throttle(15/min)` en `variants`/`hashtags`/`improve` (además de la cuota mensual por plan). | Activo en producción (throttler condicional); verificado en config. |
| 8 | **LOW** | **Handshake verify-token de Meta** con `===`. | `safeEqual`. | Cubierto por `safeEqual`. |
| 9 | **LOW** | **Login timing oracle** (scrypt solo corría para usuarios existentes) → enumeración de emails. | Se ejecuta scrypt contra un `DUMMY_PASSWORD_HASH` cuando el usuario no existe. | Igualado el trabajo en ambas ramas. |
| 10 | **LOW** | **URL de media sin validar esquema** (`@IsString`) → posible vector si se usara en `href`. | `@IsUrl({ protocols:["https"] })` en `CreateAssetDto.url`. | DTO valida https. |
| 11 | **LOW** | **`folderId` de media no validado por tenant** (INFO): un id de carpeta ajeno quedaba huérfano. | `assertOwnedFolder` en create/update. | En vivo/tests: carpeta ajena → **404**. +2 tests. |
| 12 | **LOW** | **Longitud mínima débil** de `JWT_SECRET`/`ENCRYPTION_KEY` (16). | Subida a **32** en `env.validation`. | — |
| 13 | **INFO** | **Reset de contraseña no limpiaba el lock** de fuerza-bruta. | `resetPassword` ahora hace `clearFailedLogins`. | +test de reset. |

### HIGH-2 — Land-grab de Owner en el registro (mitigado, secure-by-default)

`POST /auth/register` (público) con `workspaceSlug` de un workspace **built-in
sin miembros** convertía al primero en registrarse en Owner. Sin `NV_ADMIN_*`
sembrado, un atacante podía reclamar cualquiera de los workspaces built-in.

**Fix (seguro por defecto):** el bootstrap de Owner en el registro ahora está
detrás de `ALLOW_OPEN_WORKSPACE_CLAIM` (**off por defecto**). En producción el
provisioning es vía `NV_ADMIN_*` o invitación; el registro ya no otorga Owner
salvo que se active el flag explícitamente (self-host/single-tenant).

> **Decisión de negocio confirmada por el CTO (2026-08-08): invite-only.**
> El flag `ALLOW_OPEN_WORKSPACE_CLAIM` se deja **apagado** (valor por defecto);
> el alta de Owner es solo vía `NV_ADMIN_*` o invitación. Para habilitar alta
> self-serve abierta en el futuro bastaría con `ALLOW_OPEN_WORKSPACE_CLAIM=1`.

## Confirmado sano (no requiere acción)

- **Aislamiento multi-tenant / IDOR**: todo acceso workspace-scoped filtra por
  `workspaceSlug` (patrón check-then-act `findFirst({id, workspaceSlug})` antes
  de `update`). Verificado en vivo: Alice→recursos de Mallory **403**; contacto
  de Mallory leído/editado/borrado desde el tenant de Alice **404** (sin fuga).
- **Guards**: `JwtAuthGuard` global (via `@Global AuthModule`), `WorkspaceGuard`
  + `RolesGuard/@Roles` por controlador. `@Public` solo en auth/health/webhooks
  verificados por firma/secret. Rol `Visor`: lee **200**, muta **403**.
- **Mass assignment**: `ValidationPipe({ whitelist, forbidNonWhitelisted })` →
  campos extra (`workspaceSlug`/`id`/…) **400** en vivo.
- **Prisma/SQL**: sin `$queryRawUnsafe`; los `$queryRaw`/`Prisma.raw` usan
  literales de compilación + parámetros ligados. Sin ReDoS. Sin XSS (React
  auto-escape; sin `dangerouslySetInnerHTML`/`eval`).
- **Webhooks Stripe/Meta**: HMAC + `timingSafeEqual`, fail-closed, Stripe con
  tolerancia de timestamp (replay). Firma verificada antes de `JSON.parse`.
- **Cifrado en reposo** (AES-256-GCM, IV por llamada, tag verificado);
  **refresh tokens** de 48 bytes aleatorios almacenados solo como hash SHA-256,
  rotados/revocados; **passwords** scrypt + `timingSafeEqual`; **lockout** 5/15m;
  cookies `httpOnly`/`secure`/`sameSite` en prod. Sin secretos en logs. Filtro
  de excepciones sin fuga de stack/Prisma al cliente. Secrets críticos
  obligatorios en producción.

## Riesgos aceptados / recomendaciones (no bloqueantes)

- **`DEFAULT_PLAN=pro` cuando no hay Stripe**: sin pasarela no hay cobro (self-host);
  el abuso de coste de IA queda mitigado por el throttle (#7) y la cuota por plan.
  Recomendación operativa: fijar `DEFAULT_PLAN=free` si se desea capar por defecto.
- **Límite explícito de tamaño de body**: se recomienda fijarlo en el proxy
  (Nginx) —no se añadió en la app para no introducir dependencias directas.
- **404 vs 403** en `WorkspaceGuard` (oráculo de existencia de slug): impacto
  mínimo (los slugs built-in son config pública); se deja como está.
- **Enumeración de emails en `/auth/register`** (409): tradeoff de UX estándar
  aceptado.
