Abres las herramientas de desarrollo del navegador, pestaña Red, y revisas la respuesta de un endpoint que devuelve un archivo junto con el usuario que lo subió. Dentro del JSON aparece algo que nunca debió salir del servidor:
{
"id": 12,
"nombreArchivo": "informe.pdf",
"subidoPor": {
"id": 7,
"nombre": "Ana",
"apellido": "Pérez",
"username": "ana",
"password": "$2b$10$<hash-de-bcrypt>"
}
}
Lo mismo pasa en los listados de usuarios. Aunque el hash de bcrypt no es la contraseña, exponerlo facilita ataques de fuerza bruta fuera de línea y revela campos internos. Este artículo explica por qué sucede en NestJS con TypeORM y cómo cerrarlo con select: false, con una segunda capa de serialización y con pruebas que lo impidan en el futuro.
Por qué pasa
Por defecto, TypeORM incluye todas las columnas de una entidad en cada consulta. Si la columna password se declara con un simple @Column(), cualquier find, findOne o consulta con QueryBuilder la carga. Cuando una relación trae la entidad completa, como en subidoPor, el hash viaja con ella. NestJS, a su vez, serializa lo que devuelve el controlador: si ese objeto contiene el campo, sale en la respuesta. Nada falla ni lanza avisos, por eso el problema pasa inadvertido hasta que alguien mira la respuesta cruda.
La referencia de decoradores de TypeORM describe la opción que lo evita: select en @Column define si la columna se oculta por defecto en las consultas. Con select: false, los datos de la columna no aparecen en una consulta estándar.
Solución
-
Oculta la columna por defecto.
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Usuario { @PrimaryGeneratedColumn() id: number; @Column() nombre: string; @Column({ unique: true }) username: string; @Column({ select: false }) password: string; } -
Pide el hash de forma explícita solo donde se necesita, por ejemplo al validar el login. La guía de
QueryBuilderde TypeORM indica que, con una columnaselect: false, hay que usaraddSelectpara recuperarla:const usuario = await this.usuarios .createQueryBuilder('usuario') .addSelect('usuario.password') .where('usuario.username = :username', { username }) .getOne(); if (!usuario || !(await bcrypt.compare(clave, usuario.password))) { throw new UnauthorizedException(); }La documentación solo describe
addSelectpara este fin. No se da por hecho que unfindcon la opciónselectincluya columnas ocultas; si prefieres esa forma, compruébalo con una prueba en tu versión de TypeORM. -
Audita cada lectura de
.password. Tras ocultar la columna, cualquier código que la use recibiráundefined. Búscalo para añadiraddSelectdonde corresponda (login, cambio de contraseña, verificación de la contraseña actual) y no en otros sitios:rg -n "\.password|password:" src -
En las relaciones, selecciona solo los campos necesarios. Con
leftJoin(sinAndSelect) yaddSelectcon una lista de columnas, el endpoint devuelve únicamente lo que el cliente usa:const archivo = await this.archivos .createQueryBuilder('archivo') .leftJoin('archivo.subidoPor', 'subidoPor') .addSelect(['subidoPor.id', 'subidoPor.nombre', 'subidoPor.apellido']) .where('archivo.id = :id', { id }) .getOne();Con la columna oculta, el hash tampoco debería cargarse cuando la entidad llega como relación. La documentación de TypeORM no detalla ese caso, así que confírmalo con la prueba del paso 6.
-
Añade una segunda capa con serialización (opcional). La documentación de NestJS describe
ClassSerializerInterceptor, que aplicainstanceToPlain()a lo que devuelve el controlador y respeta los decoradores declass-transformer:import { Exclude } from 'class-transformer'; @Entity() export class Usuario { // ... @Exclude() @Column({ select: false }) password: string; } @UseInterceptors(ClassSerializerInterceptor) @Controller('usuarios') export class UsuariosController { // ... }Hay una limitación importante: la documentación aclara que el controlador debe devolver una instancia de la clase. Si devuelves objetos planos, por ejemplo con
getRawMany(), los decoradores no se aplican. Por eso@Exclude()complementa aselect: false, pero no lo sustituye. Como el decorador solo afecta a la serialización, el login sigue pudiendo leerusuario.passworden el código. -
Cubre ambos comportamientos con pruebas. Una verifica que el login pide el hash; la otra, que la respuesta no lo incluye:
it('pide la contraseña de forma explícita al validar el login', async () => { const hash = await bcrypt.hash('secreto', 4); const qb = { addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue({ id: 7, password: hash }), }; repo.createQueryBuilder = jest.fn().mockReturnValue(qb); await servicio.validarCredenciales('ana', 'secreto'); expect(qb.addSelect).toHaveBeenCalledWith('usuario.password'); }); it('la respuesta no incluye el hash', async () => { const res = await request(app.getHttpServer()).get('/archivos/12').expect(200); expect(JSON.stringify(res.body)).not.toContain('password'); });
Cómo verificar que quedó resuelto
- Repite la petición que mostraba el hash y abre la respuesta en la pestaña Red. Debe contener solo los campos esperados (por ejemplo,
id,nombreyapellidodel usuario). - Comprueba el login con una contraseña correcta y con una incorrecta. La primera debe iniciar sesión y la segunda responder 401. Si el login falla siempre, probablemente falta el
addSelect. - Revisa también los listados de usuarios y cualquier endpoint que cargue usuarios como relación.
- Ejecuta la suite completa y la compilación (
npm testynpm run build). Las dos pruebas nuevas deben pasar y el número total de pruebas debe haber aumentado.
Variantes y errores relacionados
El login falla o bcrypt.compare lanza un error tras el cambio
Es el síntoma de haber ocultado la columna sin añadir addSelect en la consulta del login: usuario.password llega como undefined. Revisa el servicio de autenticación y cualquier otra ruta que compare contraseñas.
Otros campos sensibles
La misma técnica sirve para tokens de recuperación, códigos de verificación y banderas internas. Aplica select: false y carga el campo solo en la consulta que lo necesita.
Respuestas con una lista blanca explícita
Una alternativa más estricta es no devolver nunca entidades y mapearlas a una clase de respuesta con solo los campos públicos. Evita depender de que nadie olvide ocultar un campo nuevo.
Si el hash ya estuvo expuesto
Si el endpoint estuvo disponible en producción, considera invalidar las sesiones activas y pedir un cambio de contraseña a los usuarios afectados, además de corregir el código.
Resumen
- Sin
select: false, TypeORM carga la columnapassworden cualquier consulta y NestJS la serializa en la respuesta, también cuando el usuario llega como relación. - Declara
@Column({ select: false })y carga el hash explícitamente con.addSelect('usuario.password')solo en el login y el cambio de contraseña. - Audita todas las lecturas de
.passwordy limita los campos de las relaciones. ClassSerializerInterceptorcon@Exclude()es una segunda capa, válida solo para instancias de clase.- Fija el comportamiento con pruebas del login y de la forma de la respuesta.
Comentarios
Publicar un comentario