Ir al contenido principal
Knowledge Base
seguridad jwt autenticación xss csrf rate-limiting

Arquitectura de seguridad: autenticación JWT y prevención de vulnerabilidades

Cómo implementamos la seguridad en Four-Points PMS: autenticación JWT con cookies HttpOnly, prevención de XSS/CSRF/SQL injection, rate limiting y protección contra ataques comunes.

HotelCode

La seguridad no es un módulo más del sistema, es la base sobre la que se construyen todos los demás. Este artículo describe la arquitectura de seguridad implementada en Four-Points PMS: autenticación JWT, protección contra vulnerabilidades comunes, y las decisiones de diseño que garantizan que los datos del hotel estén protegidos.

→ Este artículo es técnico. Si buscas una visión general de los problemas que resuelve un sistema de gestión hotelera, empieza por 4 problemas operativos que frenan tu hotel.

Sistema de autenticación

Arquitectura general

Four-Points utiliza tokens JWT stateless almacenados en cookies HttpOnly. No hay sesiones en el servidor, no hay tabla de sesiones en la base de datos, y no se almacenan tokens en localStorage ni sessionStorage en el frontend. El backend es la única autoridad para establecer y limpiar cookies.

┌──────────────┐     POST /api/auth/login      ┌──────────────┐
│   Frontend   │ ──────────────────────────────→│   Backend    │
│   Next.js 16 │                                │  Express 5   │
│              │ ←──────────────────────────────│              │
│              │   Set-Cookie: access_token     │              │
│              │   Set-Cookie: refresh_token    │              │
└──────────────┘                                └──────┬───────┘
                                                       │
                                                       ▼
                                                ┌──────────────┐
                                                │   MySQL      │
                                                │  (users,     │
                                                │   roles)     │
                                                └──────────────┘

Tokens JWT

Algoritmo: HS256 (explícitamente forzado en firma y verificación).

Clave secreta: SECRET_JWT_KEY, validada en el arranque del servidor:

  • Debe existir como variable de entorno
  • Longitud mínima de 32 caracteres (si no, el servidor rechaza arrancar)

Tipos de token y expiración:

TokenExpiraciónCampo type en payload
Access token15 minutos'access'
Refresh token7 días'refresh'

Ambos se firman con la misma clave secreta. Se distinguen por el campo type en el payload.

Payload JWT:

{
  id: string        // UUID del usuario
  username: string  // nombre de usuario
  role: string      // nombre del rol (admin, recepcionista, etc.)
  demo?: boolean    // presente y true solo para la cuenta demo
  type: 'access' | 'refresh'
  iat?: number      // emitido en (añadido por jwt.sign)
  exp?: number      // expira en (añadido por jwt.sign)
}

Configuración de cookies

const cookieOptions = {
  httpOnly: true,                              // Siempre — XSS no puede robarlas
  secure: process.env.NODE_ENV === 'production', // HTTPS solo en producción
  sameSite: 'lax',                             // Protección CSRF
  path: '/',
}
PropiedadDesarrolloProducción
httpOnlytruetrue
securefalsetrue (HTTPS obligatorio)
sameSite'lax''lax'
path'/''/'
CookiemaxAgePropósito
access_token15 minutosToken de acceso a la API
refresh_token7 díasRenovación automática del access token

Flujo completo de autenticación

Login

POST /api/auth/login
Cadena de middleware: loginIpLimiter → loginLimiter → login handler
  1. Validar username y password del body (ambos obligatorios)
  2. Buscar el usuario en MySQL con SELECT ... FROM users INNER JOIN roles WHERE username = ?
  3. Si el usuario no existe, comparar la contraseña contra un hash ficticio (dummy hash) para prevenir ataques de timing que distingan “usuario no existe” de “contraseña incorrecta”
  4. Rechazar usuarios inactivos (is_active = 0)
  5. Rechazar la cuenta demo (no puede login con contraseña)
  6. Si falla: esperar hasta que hayan pasado 600ms desde el inicio de la petición, luego devolver 401
  7. Si tiene éxito: generar access token (15m) y refresh token (7d), establecer ambas cookies, devolver { success: true, user: {...} } (sin password)

Peticiones autenticadas

Cualquier ruta protegida
Cadena: authenticateToken → demoRestriction → demoWriteLimiter → handler

authenticateToken:

  1. Busca el token en req.cookies.access_token
  2. Si no está en cookies, busca en Authorization: Bearer <token>
  3. Si no hay token: 401
  4. Verifica firma, algoritmo (solo HS256) y expiración
  5. Verifica que payload.type === 'access' (rechaza refresh tokens usados como access)
  6. Establece req.user = { id, username, role, isDemo }
  7. Encadena demoRestriction y demoWriteLimiter

Renovación automática (refresh)

POST /api/auth/refresh-token
Cadena: refreshLimiter → refreshToken handler

El frontend detecta cualquier respuesta 401 (excepto en rutas de auth) y automáticamente:

  1. Llama a POST /api/auth/refresh-token
  2. Si hay peticiones concurrentes con 401, las encola (solo un refresh a la vez)
  3. Si el refresh tiene éxito, reintenta la petición original
  4. Si falla (3 intentos máximos), limpia cookies y redirige a /login

El backend, al renovar:

  1. Verifica el refresh token
  2. Comprueba en MySQL que el usuario sigue existiendo y está activo
  3. Genera nuevos access y refresh tokens (sliding sessions)
  4. El rol se lee de MySQL en cada renovación — si un admin cambia el rol de un usuario, el cambio se aplica en el siguiente refresh

Logout

POST /api/auth/logout (sin autenticación requerida)

Limpia ambas cookies con las mismas opciones (httpOnly, secure, sameSite, path). El frontend redirige a /login.

Almacenamiento de contraseñas

Librería: bcrypt.

Rondas de sal: configurable vía SALT_ROUNDS (env), validado en el arranque: entero entre 10 y 15, por defecto 10.

Política de contraseñas:

  • Longitud mínima: 12 caracteres (para contraseñas nuevas o cambiadas)
  • Longitud máxima: 72 caracteres (límite de bcrypt)
  • El login acepta contraseñas más cortas (compatibilidad con contraseñas pre-existentes a la política)

Prevención de ataques de timing en el login:

// Si el usuario no existe, comparar contra un hash ficticio
const DUMMY_HASH = '$2b$10$NZRVB2LhoSY6QTIjXL9PjeglXTf0F0uyXSM42Nh89IFBEhi8jU2U6'
const passwordToCompare = user?.password || DUMMY_HASH
const isPasswordValid = await bcrypt.compare(password, passwordToCompare)

// Ambas rutas (usuario no encontrado / contraseña incorrecta)
// esperan hasta 600ms antes de responder
await waitForFailureFloor(startedAt)

Esto impide que un atacante distinga entre “usuario no existe” y “contraseña incorrecta” midiendo el tiempo de respuesta.

Control de acceso basado en roles (RBAC)

El sistema exporta funciones de verificación de rol:

MiddlewareRoles permitidosUso
isAdminadminGestión de usuarios, backoffice, config
isOwnerOrAdminadmin O dueño del recursoPerfil propio
canManageGroupsadmin, group-adminCRUD de grupos
canManageCashieradmin, recepcionista, group-adminOperaciones de caja
canViewReportsadminReportes de caja
canAccessMaintenanceadmin, recepcionista, group-admin, mantenimientoMódulo mantenimiento
canAccessBackofficeadminFacturas, proveedores
canResetChecklistadmin, recepcionistaReset de checklists

Restricciones de la cuenta demo

La cuenta demo (is_demo = 1) tiene un rol admin pero con restricciones en tres niveles:

  1. demoRestriction (en cada petición autenticada): bloquea todas las subidas de archivos (multipart)
  2. denyDemo (por ruta): bloquea la ruta completamente (incluso lectura). Aplicado a: registro, cambio de perfil, cambio de contraseña, avatar
  3. denyDemoWrites (por grupo de rutas): permite lectura pero bloquea escritura. Aplicado a: usuarios, configuración, departamentos

Cada intento bloqueado se registra en demo_activity_log con: user_id, método, ruta, body_preview (campos sensibles redactados), IP y user agent.

Prevención de vulnerabilidades

XSS (Cross-Site Scripting)

Backend: API exclusivamente JSON

El backend nunca sirve HTML. Todas las respuestas son application/json. Esto elimina el XSS almacenado y reflejado en el lado del servidor: no hay punto de inyección de scripts en respuestas API.

Cabeceras Helmet (backend):

X-Content-Type-Options: nosniff       // impide MIME sniffing
X-Frame-Options: SAMEORIGIN           // impide clickjacking
X-XSS-Protection: 0                   // recomendación moderna (desactivar)
Strict-Transport-Security             // HSTS
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Resource-Policy: same-origin
Origin-Agent-Cluster: ?1

CSP deshabilitado en el backend — al ser una API JSON pura, no sirve HTML ni assets, por lo que CSP no es necesario.

Frontend: Content Security Policy

El frontend (next.config.ts) aplica una CSP estática en todas las rutas:

default-src 'self'
script-src 'self' 'unsafe-inline' [+ 'unsafe-eval' en dev]
style-src 'self' 'unsafe-inline'
img-src 'self' blob: data: <apiOrigin> https://res.cloudinary.com https://images.unsplash.com
connect-src 'self' <apiOrigin>
frame-src 'self' blob:
worker-src 'self' blob:
object-src 'none'
base-uri 'self'
form-action 'self'
frame-ancestors 'none'
upgrade-insecure-requests (solo producción)

Nota: se usa 'unsafe-inline' en script-src porque Next.js requiere scripts inline. Esto es una limitación conocida de la CSP estática sin nonces (un nonce requeriría renderizado por petición). La CSP bloquea conexiones, frames y formularios hacia orígenes no listados.

Frontend: escaping de salida

React escapa automáticamente el contenido renderizado en JSX. No hay uso de dangerouslySetInnerHTML en el código base. Los comentarios de checklist y los cuerpos de texto se renderizan como texto plano por React.

No hay librerías de sanitización (DOMPurify, xss, sanitize-html) porque no son necesarias: el backend no sirve HTML, y el frontend usa el escaping nativo de React.

Archivos privados

Los archivos servidos a través del proxy del backend incluyen:

X-Content-Type-Options: nosniff       // impide MIME sniffing
Cross-Origin-Resource-Policy: same-site
Cache-Control: private, max-age=300   // 5 min, solo cache privada

El nombre de archivo en Content-Disposition se sanitiza:

const safe = options.filename.replace(/[^\w.-]/g, '_')

CSRF (Cross-Site Request Forgery)

No se usan tokens CSRF. La prevención de CSRF se implementa con dos capas complementarias:

Capa 1: Validación de Origin en CORS

origin: (origin, callback) => {
  if (!origin || origins.includes(origin)) {
    callback(null, true)
    return
  }
  callback(new Error('Origen no permitido'))  // → 403
}
  • Una petición cuyo Origin no está en la lista permitida recibe 403 antes de que se ejecute cualquier ruta
  • Esto incluye las “simple requests” que los navegadores envían sin preflight (form POST, multipart uploads, text/plain)
  • Las peticiones sin Origin se permiten (server-to-server, curl, Postman — estas no llevan cookies de víctima)

Cobertura de tests: se verifica que peticiones JSON, form, multipart y text/plain desde orígenes no permitidos son rechazadas con 403.

Capa 2: cookies SameSite

sameSite: 'lax'  // en ambas cookies de sesión

Un POST cross-site no enviará las cookies. Esto proporciona una segunda capa de defensa incluso si el CORS fallara.

Inyección SQL

Todas las consultas a la base de datos usan queries parametrizadas con mysql2:

await db.query('SELECT * FROM users WHERE id = ?', [userId])

MySQL con mysql2 no permite inyección SQL cuando se usan queries parametrizadas. Las consultas son estructuradas y los valores se pasan como parámetros tipados.

No hay interpolación de strings en SQL. Los nombres de columnas dinámicos se construyen desde listas predefinidas, nunca desde input del usuario.

Prevención de inyección en LIKE (repositories/shared/like.ts):

export function likeContains(query: string): string {
  return `%${query.replace(/[\\%_]/g, (c) => `\\${c}`)}%`
}

Escapa \, % y _ para que el texto del usuario se trate literalmente en patrones LIKE.

SSRF (Server-Side Request Forgery)

La función isOwnCloudinaryUrl() protege contra SSRF al descargar archivos de Cloudinary:

export function isOwnCloudinaryUrl(value: unknown, cloudName: string): boolean {
  if (typeof value !== 'string' || !cloudName) return false
  let url: URL
  try { url = new URL(value) } catch { return false }
  return (
    url.protocol === 'https:' &&              // solo HTTPS
    url.hostname === 'res.cloudinary.com' &&  // hostname exacto
    url.port === '' &&                        // sin puerto
    url.username === '' &&                    // sin userinfo
    url.password === '' &&
    url.pathname.startsWith(`/${cloudName}/`) // nuestro cloud name
  )
}

fetchStoredFile() se niega a descargar de cualquier URL que no sea HTTPS en res.cloudinary.com dentro del cloud configurado. Además, maxRedirects: 0 — no se siguen redirecciones.

Path traversal en Cloudinary

La función isPublicIdInFolder() protege las operaciones de borrado en Cloudinary:

export function isPublicIdInFolder(publicId: unknown, folder: string): boolean {
  return (
    typeof publicId === 'string' &&
    publicId.startsWith(`${folder}/`) &&     // debe estar dentro de la carpeta
    !publicId.endsWith('/') &&               // sin trailing slash
    !publicId.includes('//') &&              // sin dobles slashes
    !publicId.split('/').includes('..') &&   // sin path traversal
    ![...publicId].some(c => c === '\\' || c.charCodeAt(0) < 32)  // sin backslash ni control chars
  )
}

Sin esta validación, un public_id manipulado por el cliente podría borrar cualquier archivo en el cloud de Cloudinary.

Seguridad en subida de archivos

Validación en tres capas (imágenes)

  1. MIME type en el fileFilter de multer (tipo declarado)
  2. Tamaño máximo: fileSize: maxBytes + 1 — multer deja de leer en el límite, así que un archivo oversized nunca se almacena completo en memoria. Devuelve 413
  3. Magic bytes después de la subida: isImageFile(buffer) verifica los bytes reales del archivo

Firmas mágicas validadas:

FormatoBytes esperados
JPEGFF D8 FF
PNG89 50 4E 47 0D 0A 1A 0A
GIFGIF87a o GIF89a (primeros 6 bytes)
WebPRIFF en 0-3 + WEBP en 8-11

Límites por tipo de archivo

RutaCampoTamaño máx.VerificaciónAlmacenamiento
POST /api/auth/me/avataravatar2MBMIME + magic bytesCloudinary
POST /api/blacklist/uploadimage5MBMIMECloudinary
POST /api/maintenance/:id/imagesimage5MBMIME + magic bytesCloudinary
POST /api/backoffice/invoices/:id/pdfpdf10MBMIMECloudinary
POST /api/checklists/.../attachmentsfile5MBMIME + magic bytesCloudinary

Almacenamiento en memoria

Todos los uploads usan multer.memoryStorage() — los archivos nunca tocan el disco del servidor. Se procesan en memoria y se suben a Cloudinary directamente.

Sanitización de nombres de archivo

export function safePublicName(filename: string): string {
  const name = filename
    .replace(/\.[^/.]+$/, '')          // eliminar extensión
    .replace(/[^a-zA-Z0-9_-]/g, '_')  // solo alfanumérico, guión, guión bajo
    .substring(0, 50)                  // máximo 50 caracteres
  return name || 'file'
}

Rate limiting

El sistema implementa múltiples limitadores de velocidad:

LimitadorVentanaMáximoClaveAplicado a
apiLimiter15 min300 reqIPTodas las rutas /api
loginLimiter15 min5 intentosIP + usernamePOST /api/auth/login
loginIpLimiter15 min20 fallosIPPOST /api/auth/login (credential stuffing)
refreshLimiter15 min30IPPOST /api/auth/refresh-token
passwordChangeLimiter1 hora3User ID o IPPATCH /api/auth/me/password
profileUpdateLimiter15 min5User ID o IPPATCH /api/auth/me/profile

Robustez en la resolución de IP

La resolución de IP implementa una lógica robusta:

  • trust proxy configurado correctamente para el despliegue
  • Valida la forma de la cadena de proxies
  • Si la cadena cambia: fail closed — usa el salto público más cercano (estricto, nunca más permisivo), registra un warning
  • Normaliza IPv6-mapped IPv4 (::ffff:1.2.3.4 → 1.2.3.4)

Seguridad en logs

Campos redactados (reemplazados por [REDACTED] en toda salida de log):

redact: {
  paths: [
    '*.password',                    // cualquier propiedad llamada "password"
    '*.token',                       // cualquier propiedad llamada "token"
    '*.refreshToken',                // cualquier propiedad llamada "refreshToken"
    'req.headers.authorization',     // cabecera Authorization
    'req.headers.cookie',            // cabecera Cookie (contiene tokens)
  ],
  censor: '[REDACTED]',
}

Sanitización del body en demo: los campos sensibles (password, currentPassword, newPassword, confirmPassword) se redactan antes de registrar el body en demo_activity_log.

Eventos de seguridad registrados (todos a nivel warn con prefijo [SECURITY]):

  • Rate limit excedido (login, refresh, demo, password)
  • Fallos de login (con razón: user_not_found, inactive_user, demo_account, invalid_password)
  • Orígenes CORS bloqueados
  • Cambios en la cadena de proxies
  • Intentos bloqueados de la cuenta demo (con ruta, método, body preview, IP, user agent)

Manejo de errores

app.use((err, req, res, _next) => {
  if (err.status && err.status < 500) {
    res.status(err.status).json({ error: err.message })
    return
  }
  logger.error({ err, path: req.path, method: req.method }, 'Unhandled error')
  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Error interno del servidor'
      : err.message,
  })
})
  • Errores 4xx: se envía el mensaje (son errores controlados y esperados)
  • Errores 5xx en producción: mensaje genérico — sin stack trace, sin mensaje interno
  • Errores 5xx en desarrollo: se envía el mensaje para debugging
  • Error completo con stack: se registra en el servidor vía logger

Otras medidas de seguridad

Cache-Control

Todas las respuestas /api reciben Cache-Control: no-store. Los datos personales y financieros (blacklist, caja, facturas) no deben cachearse en navegadores ni proxies.

Tamaño máximo del body

app.use(express.json({ limit: '2mb' }))

X-Powered-By deshabilitado

app.disable('x-powered-by')           // Express

Cambio de contraseña invalida sesiones

updatePassword limpia ambas cookies y requiere re-login.

Actualización de perfil re-emite tokens

updateProfile genera nuevos tokens con el username actualizado.

Soft delete de usuarios

Los usuarios nunca se borran físicamente; se marcan is_active = 0 y su username se sufija con _deleted_<timestamp>.

Autorización a nivel de objeto

  • Mensajería: cada consulta de mensajes hace JOIN a través de conversation_participants. Los usuarios solo ven mensajes de conversaciones en las que participan
  • Logbook: las operaciones de borrado/actualización están restringidas al autor (WHERE author_id = ?)
  • Backoffice: cada mutación registra created_by: req.user.id para el audit trail

Archivos privados de Cloudinary

Las fotos de blacklist y los PDFs de facturas se suben como tipo authenticated en Cloudinary. Su secure_url lleva una firma que funciona para cualquiera para siempre, por lo que la URL nunca sale del servidor. La API sirve estos archivos a través de sus propios endpoints, que verifican la sesión y el rol antes de descargar de Cloudinary y hacer proxy de los bytes al cliente.

Solo los avatares permanecen públicos (tipo upload).

Resumen de cobertura de seguridad

Amenazas cubiertas

AmenazaMecanismo de defensaCapas
XSSAPI JSON pura (sin HTML) + CSP en frontend + escaping de Astro3
CSRFValidación de Origin en CORS + cookies SameSite=Lax2
Inyección SQLQueries parametrizadas (Firestore) + whitelist de campos2
SSRFisOwnCloudinaryUrl() + maxRedirects: 02
Path traversalisPublicIdInFolder() + sanitización de nombres2
Credential stuffingloginIpLimiter (20 fallos/15min) + timing-safe login2
Brute force loginloginLimiter (5 intentos/15min por IP+user) + 600ms floor2
Session hijackingCookies HttpOnly + Secure + SameSite3
Token theft (XSS)Cookies HttpOnly (inaccesibles desde JS)1
ClickjackingX-Frame-Options: DENY + frame-ancestors: 'none'2
MIME sniffingX-Content-Type-Options: nosniff en API y archivos2
Upload maliciosoMIME + magic bytes + tamaño + memoria (sin disco)4
Replay de tokensAccess token de 15 min + refresh con verificación en BD2
Escalada de privilegiosRBAC con middlewares + verificación de tipo de token2
Enumeración de usuariosDummy hash + 600ms floor en login fallido2
Fuga de secretos en logsRedacción de password/token/authorization/cookie1
Fuga de stack tracesMensaje genérico en producción + logging solo en servidor2
Caché de datos sensiblesCache-Control: no-store en todas las respuestas API1
Archivos expuestosProxy del backend + URLs firmadas nunca al cliente2

Configuración de seguridad del frontend (Next.js)

CabeceraValorPropósito
Content-Security-PolicyCSP estática completaBloquea scripts/connections/frames no autorizados
Cross-Origin-Opener-Policysame-originAísla el contexto de browsing
X-Frame-OptionsDENYAnti-clickjacking
X-Content-Type-OptionsnosniffAnti-MIME-sniffing
Referrer-Policystrict-origin-when-cross-originControl de fuga de referers
Permissions-Policycamera=(), microphone=(), geolocation=()Desactiva APIs del navegador
Strict-Transport-Securitymax-age=31536000; includeSubDomainsFuerza HTTPS

Configuración de seguridad del backend

MecanismoConfiguraciónPropósito
HelmetCSP deshabilitado (API JSON)Cabeceras de seguridad HTTP
CORSOrigen whitelist + credentialsCSRF + control de acceso
Rate limitingMúltiples limitadores por endpointAnti-brute-force
JWTHS256, access 15m + refresh 7dAutenticación stateless
CookiesHttpOnly + Secure + SameSite=LaxProtección de sesión
Body parserLímite 2MBAnti-DoS por payload
Trust proxyConfigurado para el despliegueIP correcta detrás de CDN
File uploadsMemoria + MIME + magic bytes + tamañoAnti-upload malicioso
Error handlerMensaje genérico en producciónAnti-fuga de información
LoggingRedacción de secretosAnti-fuga en logs

Notas y consideraciones

Decisiones de diseño documentadas

  1. CSP deshabilitado en el backend: aceptable para una API JSON pura. Si en el futuro se añade algún endpoint HTML, CSP debe habilitarse.

  2. Sin tokens CSRF: la combinación de Origin checking + SameSite=Lax es la defensa elegida, válida para una API JSON con autenticación por cookies.

  3. unsafe-inline en script-src del frontend: limitación conocida de la CSP estática sin nonces. Next.js requiere scripts inline. Un nonce requeriría renderizado por petición, incompatible con el modelo de Next.js App Router.

  4. Cross-Origin-Resource-Policy: same-site (no same-origin) para archivos privados: el API y el frontend están en subdominios diferentes, por lo que same-origin rompería la carga de recursos.

  5. Sin versionado de API: todas las rutas están bajo /api/ sin prefijo de versión. Los cambios breaking se manejan con coordinación de deploy (frontend y backend se despliegan juntos).

Ausencias notables

  • No hay 2FA (autenticación de dos factores). El sistema depende exclusivamente de contraseña + JWT
  • No hay bloqueo de cuenta tras N intentos fallidos. Se previene con rate limiting por IP, pero una cuenta específica podría ser objetivo de un ataque distribuido
  • No hay rotación forzada de contraseñas. Las contraseñas se cambian solo por iniciativa del usuario o un admin
  • No hay auditoría de acceso (quién accedió a qué recurso y cuándo). Solo hay log de eventos de mutación (cambios de estado, creación, borrado)

¿Listo para digitalizar la operativa de tu hotel?

Prueba la demo sin registro o contáctanos para una presentación personalizada.