Logo QuetzyQuetzy TFG

7. Seguridad

STATUS: borrador (v1) Estimación: 5-6 páginas

7.1 Identificación de riesgos

La siguiente tabla recoge los riesgos de seguridad identificados durante el desarrollo y operación del sistema, incluyendo un incidente real reportado por una autoridad externa (R-05). La columna Mitigación referencia las secciones donde se documenta la implementación concreta. Los riesgos no mitigados se documentan como deuda explícita.

IDRiesgoProb.ImpactoMitigación aplicadaEstado
R-01Robo de sesión por XSSMediaAltoCookie httpOnly impide acceso desde JS; sameSite=lax limita envío cross-site (ver 7.3).Mitigado
R-02CSRF en mutacionesBajaMediosameSite=lax en cookie + Server Actions de Next.js con token CSRF implícito.Mitigado
R-03Inyección SQLBajaAltoSDK Insforge usa queries parametrizadas. Cero concatenación de strings SQL en el código (verificado por grep exhaustivo).Mitigado
R-04Inyección de input no validadoMediaMedio16 schemas Zod validan todas las API routes. 10 archivos de test verifican rechazo de payloads malformados con 400.Mitigado
R-05Exposición de PostgreSQL al exteriorAltaAltoPuertos 5432 y 5430 expuestos públicamente. Detectado el 6/mayo/2026 por el BSI alemán. Corregido con reglas UFW DENY explícitas (detalle en 7.6).Resuelto
R-06Filtración de secrets en repositorioBajaAlto.gitignore excluye .env*.local (.gitignore:13). GitHub Secrets para CI/CD. GitGuardian Security Checks activo como check en PRs.Mitigado
R-07Acceso no autorizado a webhook LiveKitBajaMedioFirma HMAC-SHA256 verificada con WebhookReceiver (src/lib/calls/livekit.ts:77-78). Test de rechazo sin firma (calls-routes.test.ts:292).Mitigado
R-08Acceso no autorizado al cronBajaBajoHeader x-cron-secret contra env var CRON_SECRET (src/app/api/cron/notifications/route.ts:9-11).Mitigado
R-09Audio asimétrico en llamadasMediaMedioBug del SDK livekit-client@2.18.9. Fix parcial aplicado (ver 6.3). No es un riesgo de seguridad estricto pero impacta disponibilidad.Aceptado
R-10Suplantación de identidad en presenciaBajaBajoRLS presence_upsert_own verifica user_email contra claim JWT (data_model.sql:453-456).Mitigado
R-11Acceso no autorizado a archivos adjuntosBajaMedioBucket chat-attachments con URLs públicas (key autogenerado). Técnicamente listable si se conoce el patrón. Deuda: signed URLs con expiración.Aceptado
R-12Enumeración de cuentas en loginMediaBajoMensaje genérico "Credenciales incorrectas." (src/app/(auth)/actions.ts:36). Test dedicado (actions.test.ts:78-91).Mitigado
R-13Fuerza bruta en loginMediaAltoNO mitigado en v1. Deuda: rate limiting por IP/usuario.Pendiente
R-14Test harness habilitado en producciónBajaAltoENABLE_TEST_HARNESS === "true" en middleware (:29) + variable no definida en .env.production.Mitigado

7.2 Autenticación y autorización

Esta sección profundiza en las capas de verificación de identidad y control de acceso, complementando el flujo de login documentado en 6.3 y el diagrama de secuencia de la Figura 5.7.

Autenticación

El sistema usa Insforge Auth con email y contraseña. No existe registro público: solo el administrador crea usuarios desde la consola de Insforge. Esta restricción es deliberada para un sistema interno de equipo. A fecha de cierre del TFG existen dos usuarios: fran@quetzy.eu y manu@quetzy.eu.

El flujo completo (Figura 5.7) atraviesa cinco pasos: validación Zod del input → verificación de credenciales contra Insforge Auth → recepción de tokens JWT → persistencia manual de cookie httpOnly (“Plan B”, actions.ts:39-46) → redirección a /. El Server Action signinAction (actions.ts:16-49) es el único punto de entrada. Si las credenciales son incorrectas, devuelve "Credenciales incorrectas." (:36) sin revelar la causa, mitigando R-12.

Autorización

La autorización opera en cinco capas:

1. Middleware (src/middleware.ts:8-40). Intercepta todas las peticiones y verifica la presencia de la cookie __insforge_session (:33). Las rutas exentas se definen en PUBLIC_PATHS (:8: /login, /api/health, /api/calls/webhook, /api/cron/notifications) y PUBLIC_PREFIXES (:9: /_next/, /api/__test__/). El middleware solo comprueba existencia de cookie, no valida el JWT. Eso ocurre en la capa siguiente.

2. Validación JWT server-side. El layout del ERP y cada API route que muta datos llaman a getSessionUser() (src/lib/auth/session.ts:40-75), que ejecuta jwtVerify() de jose contra INSFORGE_JWT_SECRET (:57-60). Si el token es inválido, expirado o tiene claims incompletos (sub, email), retorna null y el layout redirige a /login. Los 10 tests de session.test.ts cubren todos estos casos, incluyendo token firmado con secreto incorrecto (:107-118).

3. Ownership en API routes. Los handlers que mutan datos extraen el email del usuario autenticado y lo pasan al repositorio. El repositorio verifica ownership cuando aplica: pauseTimer() lanza 403 si el usuario no es propietario del timer (time-entry-repository.test.ts:288-295).

4. RLS en base de datos. Aplicado exclusivamente a erp_presence (data_model.sql:448-456). La política presence_upsert_own verifica que user_email coincida con el claim email del JWT via current_setting('request.jwt.claims'). Limitación v1: el resto de tablas no tienen RLS porque no existe RBAC granular (ver 3.6). Todos los usuarios autenticados tienen acceso completo. Aceptable para equipo interno de 2-20 personas; insuficiente para multi-tenant.

5. Autenticación server-to-server. Dos endpoints reciben peticiones sin cookie de usuario:

  • Webhook LiveKit (/api/calls/webhook): firma HMAC-SHA256 verificada con WebhookReceiver del SDK de LiveKit (src/lib/calls/livekit.ts:77-78). Devuelve 401 si la firma es inválida (webhook/route.ts:24-27).
  • Cron de notificaciones (/api/cron/notifications): header x-cron-secret comparado contra CRON_SECRET (notifications/route.ts:9-11). La comparación usa !== (no constant-time), con riesgo teórico de timing attack bajo en la práctica por la alta entropía del secreto.

7.3 Gestión de sesiones

La sesión se persiste en una cookie __insforge_session con la configuración definida en src/lib/auth/session.ts:26-32:

FlagValorPropósito
httpOnlytrueImpide acceso desde JavaScript (mitiga XSS → R-01).
securetrue en producciónSolo se transmite por HTTPS. Desactivado en localhost.
sameSite"lax"No se envía en peticiones cross-site embebidas (mitiga CSRF → R-02).
path"/"Disponible en todas las rutas.
maxAge604.800 s (7 días)Expiración automática.

El payload contiene accessToken y refreshToken serializados como JSON. El accessToken es un JWT firmado con HS256 contra INSFORGE_JWT_SECRET.

Refresh token. El flujo de refresh NO está implementado en v1. Si el accessToken expira antes de los 7 días de maxAge, el usuario es redirigido a login. Deuda explícita: implementar refresco transparente.

Logout. signoutAction (actions.ts:51-73) ejecuta tres operaciones secuenciales: (1) limpieza best-effort de presencia, (2) signOut() del SDK, (3) borrado de cookie. El orden es relevante: getSessionUser() necesita leer la cookie que clearSessionCookie() destruye, por lo que la presencia se limpia primero. Si falla, el logout continúa igualmente (actions.ts:53-55).

Invalidación server-side. Insforge no expone API de invalidación de tokens. Si un atacante copia la cookie antes de un logout, puede usarla hasta que expire. Deuda: token blacklist.

Token de realtime. El endpoint /api/auth/realtime-token (realtime-token/route.ts:13-33) extrae el accessToken de la cookie y lo devuelve al cliente browser para la conexión WebSocket de Insforge Realtime. La validación de permisos la ejecuta el servidor Insforge al aceptar la conexión. Tests verifican rechazo sin cookie (:39-47) y con JSON malformado (:49-57).

7.4 Cifrado de datos

En tránsito

Todo el tráfico pasa por HTTPS, gestionado automáticamente por Caddy con certificados Let’s Encrypt vía ACME (configuración en 5.3). HTTP/3 habilitado. El tráfico WebSocket de Insforge Realtime viaja sobre TLS (WSS). El tráfico WebRTC de LiveKit usa DTLS-SRTP, estándar del protocolo que cifra audio y video entre cliente y servidor SFU.

En reposo

Base de datos. PostgreSQL 15.15 corre sobre el filesystem del VPS. Hetzner CX33 no ofrece cifrado de disco transparente por defecto. Los datos en disco no están cifrados a nivel de filesystem. Deuda: evaluar LUKS o cifrado de volumen.

Passwords. Hasheadas por Insforge Auth internamente (bcrypt o argon2, gestionado por el BaaS sin acceso directo a hashes).

Secrets en el VPS. .env.production almacena secretos en plano, con acceso restringido por permisos Unix y SSH por clave. Deuda futura: gestor de secretos dedicado.

Firma de tokens

Dos pares de claves independientes:

  • Sesión ERP: HS256 con INSFORGE_JWT_SECRET, compartido entre Insforge y Next.js.
  • Tokens LiveKit: firmados con LIVEKIT_API_KEY + LIVEKIT_API_SECRET. TTL de 1 hora (src/lib/calls/livekit.ts:46). Permisos: roomJoin, canPublish, canSubscribe, canPublishData (:49-55).

7.5 Buenas prácticas aplicadas

Checklist verificada contra el código del repositorio.

Implementadas:

  • Validación de input con Zod en todas las API routes (16 schemas, 10 con tests de rechazo).
  • Cookie httpOnly + secure + sameSite=lax (7.3).
  • TLS automático en todos los dominios vía Caddy + Let’s Encrypt.
  • Secrets fuera del repo: .gitignore excluye .env*.local, CI/CD usa GitHub Secrets.
  • GitGuardian Security Checks analiza secrets en cada push.
  • Verificación de firma HMAC-SHA256 en webhooks LiveKit.
  • Header secreto de alta entropía en endpoint cron.
  • Mensaje genérico anti-enumeración en login.
  • Test harness gated por variable de entorno + NODE_ENV.
  • Patrón Decision/Reason/Alternative en código de seguridad (135 comentarios Decision en 94 archivos).
  • Doble implementación mock/producción permite testear sin tocar BD real.
  • UFW activo con whitelist de puertos; puertos de BD bloqueados explícitamente tras R-05.
  • Deploy vía SSH con clave pública.
  • Historial auditable via Conventional Commits + squash merge.
  • Zero SQL crudo: todas las queries parametrizadas vía SDK.

No implementadas (deuda):

  • Rate limiting en login y API routes (R-13).
  • Cifrado on-disk de la base de datos.
  • Flujo de refresh token.
  • RBAC granular.
  • Signed URLs con expiración en bucket de attachments (R-11).
  • Invalidación server-side de tokens (blacklist).
  • Análisis SAST/DAST/SCA automatizado en CI (7.6).

7.6 Pruebas de seguridad

Las pruebas de seguridad combinan tests automatizados en el flujo CI/CD y un incidente real que funcionó como auditoría externa. Esta sección complementa las métricas generales de testing de 6.6.

Tests automatizados con impacto en seguridad

Los tests de seguridad están distribuidos en archivos temáticos, no en una suite separada:

Middleware y control de acceso (src/middleware.test.ts, 12 tests). Verifica clasificación de rutas públicas vs. protegidas: /login, /api/health, /api/calls/webhook y /api/cron/notifications son públicas; /tickets, /clients/123, /api/tickets requieren sesión.

Validación JWT (src/lib/auth/session.test.ts, 10 tests). Acepta tokens válidos, rechaza expirados (:93-105), rechaza firma con secreto incorrecto (:107-118), rechaza cookies ausentes, malformadas o con claims incompletos, y falla si INSFORGE_JWT_SECRET no existe (:167-181).

Anti-enumeración (src/app/(auth)/actions.test.ts, 9 tests). Credenciales incorrectas devuelven "Credenciales incorrectas." sin distinguir causa (:78-91). Logout completa incluso si la limpieza de presencia falla (:147-164).

Firma de webhooks (src/app/api/calls/calls-routes.test.ts, 13 tests). Peticiones sin firma válida reciben 401 (:292-297). Endpoints de llamadas rechazan peticiones sin autenticación.

Autenticación en chat (src/app/api/chat/chat-routes.test.ts, 22 tests). Endpoints de canales, mensajes y reacciones devuelven 401 sin sesión (:127-132, :163-169, :287-293, :365-371, :416-425).

Ownership (time-entry-repository.test.ts, test en :288-295). pauseTimer() lanza 403 si usuario no es propietario.

Contratos Zod (10 archivos en src/lib/contract/). Más de 70 tests de rechazo: campos faltantes, tipos incorrectos, formatos inválidos (UUID, email) y campos extra en modo strict.

Token de realtime (realtime-token/route.test.ts, 3 tests). Devuelve 401 sin cookie (:39-47) y con JSON malformado (:49-57).

Incidente real: exposición de PostgreSQL (R-05)

El 6 de mayo de 2026, el BSI alemán (Bundesamt fur Sicherheit in der Informationstechnik) notificó al administrador del VPS que los puertos 5432 y 5430 (PostgreSQL de Insforge) eran accesibles desde internet. La causa: Docker mapea contenedores a 0.0.0.0 por defecto, y aunque UFW tenía política general DENY, no existían reglas explícitas de bloqueo para estos puertos. Docker inserta sus propias reglas iptables que preceden a las de UFW, un problema conocido y documentado en la comunidad.

Mitigación inmediata:

sudo ufw deny 5432/tcp sudo ufw deny 5430/tcp sudo ufw reload

Lección: las reglas de firewall deben ser explícitas para cada puerto sensible en entornos con Docker. Los defaults de UFW no son suficientes porque Docker gestiona sus propias cadenas iptables. La verificación post-corrección confirmó que ambos puertos dejaron de ser accesibles. Las reglas resultantes se documentan en 5.3.

Análisis pendiente (deuda)

  • Pentest formal (interno o externo) contra la instancia de producción.
  • Revisión OWASP Top 10 sistemática, más allá de las mitigaciones puntuales de 7.1.
  • SAST (SonarQube, Snyk Code) para detección estática de vulnerabilidades.
  • SCA (Software Composition Analysis) para auditoría de dependencias. Dependabot está activo por defecto en GitHub pero no se ha revisado activamente.
  • DAST (OWASP ZAP, Burp Suite) contra la aplicación en ejecución.

La priorización de estas prácticas forma parte del backlog post-TFG junto con el triage de issues mencionado en 3.6.