본문으로 건너뛰기
개발 머꼬
개발 노트NestJS
hohyeon.dev22

사용자 조회 응답에 비밀번호 해시가 그대로 실려 나간 사고

  • #Engineering Note
  • #NestJS
  • #Security

문제 발생

사용자 조회 API 응답에 비밀번호 해시가 그대로 실려 나갔습니다.

{ "id": 7, "email": "[email protected]", "password": "$2b$10$...", "role": "user" }

컨트롤러마다 손으로 지우고 있었는데, 새로 만든 엔드포인트에서 빠뜨린 것이었습니다.

const { password, ...rest } = user;   // 한 곳이라도 빠뜨리면 유출
return rest;

원인 분석

응답 정리를 핸들러마다 하는 구조가 원인입니다. 엔드포인트가 늘어날수록 빠뜨릴 확률이 올라가고, 빠뜨린 곳은 아무 오류도 내지 않습니다.

NestJS는 이걸 한 곳에서 강제하는 도구를 제공합니다. ClassSerializerInterceptor는 class-transformer의 instanceToPlain()을 적용해 응답을 변환합니다. 엔티티 클래스에 @Exclude()를 붙인 속성은 응답에서 제거됩니다.

다만 결정적인 조건이 있습니다. 공식 문서가 경고로 적습니다 — 반드시 실제 클래스 인스턴스를 반환해야 합니다. 예를 들어 { user: new UserEntity() } 같은 평범한 자바스크립트 객체를 반환하면 제대로 직렬화되지 않습니다. 인터셉터는 클래스 인스턴스에서만 데코레이터를 해석할 수 있기 때문입니다.

ORM이 평범한 객체를 돌려주는 경우가 많아서, 이 조건을 모르면 "데코레이터를 붙였는데 아무 일도 안 일어난다"가 됩니다.

해결 방안

  1. 엔티티에 @Exclude()를 붙입니다.
import { Exclude } from "class-transformer";

export class UserEntity {
  id: number;
  email: string;

  @Exclude()
  password: string;

  constructor(partial: Partial<UserEntity>) {
    Object.assign(this, partial);
  }
}
  1. 인터셉터를 전역으로 겁니다. 한 곳에서 규칙이 강제됩니다.
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));

문서도 이 방식의 이점을 짚습니다 — 애플리케이션 전체에 적용하면 그 엔티티를 반환하는 어떤 메서드든 직렬화 규칙을 자동으로 따르게 됩니다.

  1. 반드시 인스턴스를 반환합니다. 이게 핵심입니다.
@Get(":id")
async findOne(@Param("id") id: string) {
  const row = await this.userService.findOne(id);
  return new UserEntity(row);   // ✅ 클래스 인스턴스
}
return { user: new UserEntity(row) };   // ❌ 감싸면 변환되지 않음

목록도 마찬가지로 각 항목을 인스턴스로 만듭니다.

return rows.map((row) => new UserEntity(row));
  1. 값을 가공해야 하면 @Transform()입니다. 연관 객체를 통째로 내보내는 대신 이름만 꺼내는 식입니다.
@Transform(({ value }) => value?.name)
role: RoleEntity;
  1. 별칭이나 계산 값은 @Expose()입니다. 속성 이름을 바꾸거나 함수로 값을 만들 수 있습니다.

  2. 상황별로 다르게 내보내야 하면 @SerializeOptions()입니다. instanceToPlain()에 넘길 옵션을 지정합니다. 예를 들어 접두사로 특정 속성을 제외하거나, 그룹을 지정해 운영자에게만 추가 필드를 노출할 수 있습니다.

  3. 이걸 유일한 방어선으로 삼지 않습니다. 응답 직렬화는 마지막 관문입니다. 애초에 쿼리에서 비밀번호 컬럼을 선택하지 않는 것이 더 좋습니다 — 로그나 오류 메시지로 새는 경로는 인터셉터가 막아주지 않습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.