TypeORM: la contraseña sale en el JSON (select: false)

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

  1. 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;
    }
  2. Pide el hash de forma explícita solo donde se necesita, por ejemplo al validar el login. La guía de QueryBuilder de TypeORM indica que, con una columna select: false, hay que usar addSelect para 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 addSelect para este fin. No se da por hecho que un find con la opción select incluya columnas ocultas; si prefieres esa forma, compruébalo con una prueba en tu versión de TypeORM.

  3. Audita cada lectura de .password. Tras ocultar la columna, cualquier código que la use recibirá undefined. Búscalo para añadir addSelect donde corresponda (login, cambio de contraseña, verificación de la contraseña actual) y no en otros sitios:

    rg -n "\.password|password:" src
  4. En las relaciones, selecciona solo los campos necesarios. Con leftJoin (sin AndSelect) y addSelect con 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.

  5. Añade una segunda capa con serialización (opcional). La documentación de NestJS describe ClassSerializerInterceptor, que aplica instanceToPlain() a lo que devuelve el controlador y respeta los decoradores de class-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 a select: false, pero no lo sustituye. Como el decorador solo afecta a la serialización, el login sigue pudiendo leer usuario.password en el código.

  6. 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

  1. 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, nombre y apellido del usuario).
  2. 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.
  3. Revisa también los listados de usuarios y cualquier endpoint que cargue usuarios como relación.
  4. Ejecuta la suite completa y la compilación (npm test y npm 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 columna password en 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 .password y limita los campos de las relaciones.
  • ClassSerializerInterceptor con @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.

Referencias

Comentarios