“Es una app interna, solo la usan los empleados.” Es la frase que más escucho antes de auditar un sistema, y casi siempre termina igual: cualquier usuario con sesión puede ver información que no le corresponde. En sistemas de RH, compras o auditoría eso significa sueldos, proveedores o denuncias expuestas.
Estos son los errores que más encuentro, en orden de frecuencia.
1. Cambiar el ID en la URL
/api/solicitudes/6511a... regresa la solicitud sin revisar si es tuya. El frontend nunca te muestra el enlace, pero cualquiera puede probar otros IDs. Es el famoso IDOR (insecure direct object reference).
La corrección: el alcance del usuario va en la consulta, no en un if después:
// ❌ Busca y luego (a veces) revisa
const doc = await this.model.findById(id);
// ✅ La consulta ya incluye lo que el usuario puede ver
const doc = await this.model.findOne({ _id: id, ...this.scope.for(user) });
if (!doc) throw new NotFoundException(); // mismo error si no existe o si no es tuyo
Fíjate en el último detalle: responde 404 en ambos casos. Si respondes 403 cuando existe y 404 cuando no, estás revelando qué IDs existen.
2. Filtros que solo existen en el frontend
La tabla muestra solo “mi sucursal”, pero el endpoint regresa todas y el filtro lo hace el navegador. Abre las herramientas de desarrollador y ahí está todo. Todo filtro de seguridad vive en el servidor. El frontend solo filtra por comodidad.
3. El listado protegido, el detalle no (o al revés)
Es muy común proteger GET /items y olvidar GET /items/:id, GET /items/:id/archivo o GET /items/export. Cada ruta que devuelve datos necesita la misma regla de alcance. Centralízala en un servicio para no repetirla a mano.
4. Exportaciones que filtran columnas
La pantalla oculta la columna “salario” o “costo”, pero el Excel exportado la trae. Las columnas sensibles se quitan en el servidor, antes de generar el archivo.
5. Asignación masiva
El endpoint de “actualizar mi perfil” acepta el objeto completo del cliente y lo guarda. Alguien agrega "rol": "admin" al JSON y listo. Usa DTOs con lista blanca:
// main.ts (NestJS)
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
6. Archivos con URL adivinable
/uploads/evidencia-123.pdf servido como archivo estático, sin sesión. Los adjuntos se sirven a través de un endpoint que revisa permisos y registra quién los descargó.
7. Roles que se pueden auto-ampliar
Un jefe puede delegar permisos… incluso más de los que tiene. Aplica un techo: nadie puede otorgar un alcance mayor que el propio.
8. Sin bitácora
Cuando pasa algo, nadie puede decir quién vio o cambió qué. Registra al menos: quién, qué acción, sobre qué registro, cuándo y desde dónde. Para datos sensibles, también las lecturas.
Cómo lo trabajo: primero la prueba que falla
Mi método para cada hoyo:
- Escribo una prueba que reproduce el ataque (por ejemplo, el usuario A pide el registro del usuario B).
- Confirmo que la prueba falla (el hoyo existe).
- Corrijo.
- La prueba pasa y se queda en el proyecto para que el hoyo no regrese.
Así cada corrección queda demostrada, no solo “revisada”.
Checklist
- Toda consulta incluye el alcance del usuario.
- 404 tanto si no existe como si no tienes acceso.
- Filtros de seguridad solo en el servidor.
- Detalle, archivos y exportaciones con la misma regla que el listado.
- DTOs con lista blanca.
- Delegación con techo.
- Bitácora de accesos y cambios.
¿Tienes un sistema interno que nunca ha pasado por una revisión así? Te ayudo a auditarlo.