NestJS 보안 가이드: Guard·ValidationPipe 설정에서 놓치는 6가지

NestJS 보안 점검 가이드. Guard 적용 범위, @Public 남용, ValidationPipe 옵션, 예외 필터, 응답 직렬화, Swagger 운영 노출까지 배포 전 확인할 6가지를 취약한 코드와 고친 코드로 정리했다.

보안 점검 의뢰 팀 19분 분량

NestJS API를 배포하기 전에 확인할 것은 크게 여섯 가지입니다. ① 인증 Guard가 전역에 걸려 있는지, ② @Public() 예외가 필요 이상으로 붙어 있지 않은지, ③ 전역 ValidationPipe에 whitelist·forbidNonWhitelisted·transform이 의도대로 설정됐는지, ④ 예외 필터가 내부 오류 메시지를 그대로 내보내지 않는지, ⑤ 응답 직렬화에서 비밀번호 같은 필드가 빠지는지, ⑥ Swagger 문서가 운영 환경에 공개돼 있지 않은지입니다.

이 여섯 가지는 모두 NestJS의 데코레이터와 DI 구조 때문에 생깁니다. 보안 설정이 main.ts, 모듈 프로바이더, 컨트롤러 데코레이터에 흩어져 있다 보니 코드 리뷰에서 "이 라우트에 실제로 어떤 Guard가 적용되는가"를 한눈에 보기 어렵습니다. 미들웨어 순서 문제는 Express 보안 설정 가이드에서 다뤘고, 이 글에서는 NestJS에만 있는 구조를 항목별로 짚은 뒤 컨트롤러 단위로 한 번에 점검하는 방법을 소개합니다.

예시 코드는 모두 가상의 코드이며, NestJS 공식 문서의 권장 패턴을 기준으로 했습니다. 버전에 따라 API가 조금씩 다르니 사용 중인 버전의 문서와 함께 확인하세요.

1·2. Guard: 기본값이 '열림'인 구조와 @Public 남용

1. Guard를 컨트롤러마다 붙이는 방식

NestJS에서는 Guard를 붙이지 않은 라우트는 기본적으로 누구나 호출할 수 있습니다. 초기에 @UseGuards(JwtAuthGuard)를 컨트롤러마다 붙이는 방식으로 시작하면, 나중에 추가된 컨트롤러나 메서드에서 빠지기 쉽습니다. OWASP API Security Top 10의 API5:2023(Broken Function Level Authorization)도 권한 검사를 기본 거부(deny by default)로 설계하라고 권고합니다.

// 취약: Guard를 컨트롤러마다 수동으로 붙임
@Controller('orders')
@UseGuards(JwtAuthGuard)
export class OrdersController { /* ... */ }

@Controller('admin/coupons') // 새로 추가된 컨트롤러, Guard 누락
export class AdminCouponsController {
  @Post()
  create(@Body() dto: CreateCouponDto) { /* ... */ }
}

공식 문서의 Passport 레시피처럼 APP_GUARD로 전역 등록하고, 공개할 라우트만 명시적으로 빼는 구조로 바꾸는 편이 안전합니다. app.useGlobalGuards()도 가능하지만, Guard가 Reflector 같은 의존성을 주입받아야 하므로 모듈 프로바이더로 등록하는 방식을 권합니다.

// 고친 코드: 전역 기본 거부 + 명시적 예외
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  constructor(private reflector: Reflector) { super(); }

  canActivate(context: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (isPublic) return true;
    return super.canActivate(context);
  }
}

// app.module.ts
providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard },
  { provide: APP_GUARD, useClass: RolesGuard }, // 인증 다음에 인가
],

전역 Guard가 여러 개면 등록 순서대로 실행되므로 인증 Guard가 인가 Guard보다 먼저 와야 합니다. JWT 검증 자체의 함정(서명 검증 누락, alg 혼동)은 JWT 취약점 글을 참고하세요.

2. @Public()과 역할 Guard의 '빈칸 통과'

전역 Guard를 도입한 뒤에는 반대로 @Public()이 늘어나는 문제가 생깁니다. 특히 주의할 곳은 두 군데입니다.

  • 컨트롤러(클래스) 단위 @Public(): getAllAndOverride는 메서드에 값이 없으면 클래스 값을 사용합니다. 공개 조회 API를 위해 클래스에 @Public()을 붙였다면, 나중에 같은 컨트롤러에 추가한 수정·삭제 메서드도 함께 공개됩니다.
  • @Roles()가 없으면 통과시키는 RolesGuard: 흔히 쓰는 예제는 필요한 역할 메타데이터가 없으면 return true를 반환합니다. 관리자 컨트롤러에서 @Roles('admin')을 하나라도 빠뜨리면, 로그인한 일반 사용자도 그 기능을 호출할 수 있습니다.
// 취약: 역할 지정이 없으면 모두 허용
canActivate(ctx: ExecutionContext) {
  const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [ctx.getHandler(), ctx.getClass()]);
  if (!roles) return true;
  return roles.some((r) => ctx.switchToHttp().getRequest().user?.roles?.includes(r));
}
// 고친 코드: 관리자 경로는 클래스 단위로 역할을 강제하고, 누락 시 거부
@Controller('admin/coupons')
@Roles('admin') // 클래스 단위로 기본값 지정
export class AdminCouponsController { /* ... */ }

canActivate(ctx: ExecutionContext) {
  const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]);
  if (isPublic) return true;
  const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [ctx.getHandler(), ctx.getClass()]);
  const req = ctx.switchToHttp().getRequest();
  if (req.path.startsWith('/admin') && !roles) return false; // 관리자 경로는 역할 미지정 시 거부
  if (!roles) return true;
  return roles.some((r) => req.user?.roles?.includes(r));
}

판단 기준은 간단합니다. @Public()은 메서드 단위에만 붙이고, 로그인·회원가입·헬스체크·공개 조회처럼 목록으로 설명할 수 있는 라우트로 한정합니다. 기능 단위 인가와 별개로, 다른 사용자의 리소스에 접근하는 문제(객체 단위 인가)는 서비스 계층에서 소유자를 확인해야 Guard만으로 막을 수 없다는 점도 기억해 두세요.

3. 전역 ValidationPipe: whitelist·forbidNonWhitelisted·transform

ValidationPipe를 전역으로 걸었더라도 옵션 없이 new ValidationPipe()만 쓰면, DTO에 정의되지 않은 필드가 그대로 서비스 계층까지 들어옵니다. 요청 본문을 ORM에 그대로 넘기는 코드와 만나면 role, isVerified 같은 필드가 함께 저장될 수 있습니다. 이 원리는 Mass Assignment 취약점과 같습니다.

// 취약: 옵션 없음 + 본문을 그대로 저장
app.useGlobalPipes(new ValidationPipe());

@Patch('me')
update(@Req() req, @Body() body: UpdateProfileDto) {
  return this.prisma.user.update({ where: { id: req.user.id }, data: body });
}
// 고친 코드
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,            // 데코레이터 없는 속성 제거
    forbidNonWhitelisted: true, // 제거 대신 400 오류로 거부
    transform: true,            // DTO 클래스 인스턴스로 변환
  }),
);

@Patch('me')
update(@Req() req, @Body() dto: UpdateProfileDto) {
  const { nickname, bio } = dto; // 저장할 필드를 명시적으로 고름
  return this.prisma.user.update({ where: { id: req.user.id }, data: { nickname, bio } });
}

공식 문서 기준으로 forbidNonWhitelisted는 whitelist: true와 함께 써야 동작합니다. 옵션을 켠 뒤에도 놓치기 쉬운 부분은 다음과 같습니다.

  • 중첩 객체: 중첩 DTO에 @ValidateNested()와 @Type(() => AddressDto)가 없으면 내부 객체는 검증되지 않은 채 통과할 수 있습니다.
  • @Body() body: any 또는 인터페이스 타입: ValidationPipe는 클래스 타입 정보로 검증하므로, any나 TypeScript 인터페이스로 받으면 검증이 사실상 일어나지 않습니다.
  • 암묵적 타입 변환: transformOptions: { enableImplicitConversion: true }를 켜면 쿼리 문자열 "false"가 불리언 true로 바뀌는 식의 결과가 나올 수 있습니다. 불리언·숫자 필드는 @Transform으로 명시적으로 변환하는 편이 예측하기 쉽습니다.
  • 로컬 파이프와의 중복: 특정 라우트에 옵션 없는 @UsePipes(new ValidationPipe())가 있으면, 그 라우트에서 전역 설정과 다른 동작이 나올 수 있으니 검색해서 정리합니다.

4. 예외 필터: 내부 오류를 응답으로 돌려주지 않기

NestJS의 기본 전역 예외 필터는 HttpException 계열이 아닌 예외를 500과 "Internal server error"로 바꿔 응답합니다. 기본 상태는 비교적 안전한데, 문제는 로깅이나 응답 형식을 맞추려고 만든 커스텀 catch-all 필터에서 생깁니다.

// 취약: 모든 예외의 메시지와 스택을 그대로 응답
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    res.status(500).json({ message: exception.message, stack: exception.stack });
  }
}

ORM 오류 메시지에는 테이블·컬럼 이름이나 쿼리 일부가, 외부 API 오류에는 내부 호스트 주소가 들어 있을 수 있습니다.

// 고친 코드: HttpException만 그대로, 나머지는 서버 로그로
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    if (exception instanceof HttpException) {
      return res.status(exception.getStatus()).json(exception.getResponse());
    }
    const errorId = randomUUID();
    this.logger.error({ errorId, err: exception }); // 상세 내용은 로그에만
    res.status(500).json({ statusCode: 500, message: 'Internal server error', errorId });
  }
}

useGlobalFilters()로 등록한 필터는 의존성을 주입받을 수 없으므로, 로거 서비스 등을 쓰려면 APP_FILTER로 등록합니다. 로그에 요청 본문 전체를 남기는 경우에는 비밀번호·토큰이 기록되지 않도록 마스킹도 함께 확인하세요.

5. 인터셉터 응답 직렬화: 엔티티를 그대로 반환하지 않기

컨트롤러가 ORM 엔티티를 그대로 반환하면 passwordHash, refreshToken, 내부 메모 같은 필드가 JSON에 함께 담깁니다. 공식 문서는 class-transformer의 @Exclude()와 ClassSerializerInterceptor 조합을 안내합니다.

// 취약: 조회 결과(plain object)를 그대로 반환
@Get(':id')
findOne(@Param('id') id: string) {
  return this.prisma.user.findUnique({ where: { id } }); // passwordHash 포함
}
// 고친 코드: 응답 전용 클래스 + 직렬화 인터셉터
export class UserResponse {
  id: string;
  nickname: string;
  @Exclude() passwordHash: string;
  @Exclude() refreshToken: string | null;
  constructor(partial: Partial<UserResponse>) { Object.assign(this, partial); }
}

@UseInterceptors(ClassSerializerInterceptor)
@Get(':id')
async findOne(@Param('id') id: string) {
  const user = await this.prisma.user.findUnique({ where: { id } });
  return new UserResponse(user);
}

여기서 자주 놓치는 점은 인터셉터가 클래스 인스턴스에만 데코레이터를 적용한다는 것입니다. 공식 문서도 { user: new UserEntity() }처럼 일반 객체로 감싸 반환하면 제대로 직렬화되지 않는다고 설명합니다. Prisma처럼 일반 객체를 돌려주는 ORM에서는 응답 클래스로 감싸지 않으면 @Exclude()가 아무 효과가 없습니다. 더 확실한 방법은 @Exclude()로 빼는 블랙리스트 방식 대신, 응답 DTO에 필요한 필드만 골라 담는 화이트리스트 방식입니다. DB 조회 단계에서 select로 필요한 컬럼만 가져오면 실수할 여지가 더 줄어듭니다.

6. Swagger: 운영 환경에서 문서 노출 막기

SwaggerModule.setup('api', app, document)을 환경 구분 없이 두면 운영 서버에서도 /api에 UI가, /api-json에 전체 스펙이 공개됩니다. 스펙 자체가 곧 취약점은 아니지만, 관리자용·내부용 엔드포인트 목록과 요청 스키마를 외부에 정리해서 보여주는 셈이라 공격 표면을 파악하는 데 쓰일 수 있습니다.

// 취약: 모든 환경에서 공개
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
// 고친 코드: 운영에서는 끄고, 필요하면 외부 문서만 분리
if (process.env.NODE_ENV !== 'production') {
  const config = new DocumentBuilder().setTitle('Example API').addBearerAuth().build();
  SwaggerModule.setup('docs', app, () => SwaggerModule.createDocument(app, config));
}

외부 파트너에게 문서를 제공해야 한다면 include 옵션으로 공개 모듈만 담은 문서를 따로 만들거나, 문서 경로를 사내망·VPN 또는 별도 인증 뒤에 둡니다. 스테이징 서버가 공개 도메인(예: staging-api.example.com)에 열려 있다면 스테이징도 같은 기준을 적용해야 합니다. NODE_ENV 값이 배포 환경에서 실제로 production으로 설정되는지도 함께 확인하세요.

컨트롤러 단위로 점검하는 방법

NestJS는 설정이 흩어져 있으므로 코드를 눈으로 훑는 것보다 라우트 목록을 뽑아서 메타데이터와 대조하는 편이 정확합니다. 다음은 DiscoveryService로 모든 핸들러의 공개 여부와 역할 지정을 출력하는 예시입니다(내부 점검용 스크립트, 버전에 따라 API 확인 필요).

// scripts/route-audit.ts
const app = await NestFactory.createApplicationContext(AppModule, { logger: false });
const discovery = app.get(DiscoveryService);
const scanner = app.get(MetadataScanner);
const reflector = app.get(Reflector);

for (const wrapper of discovery.getControllers()) {
  const { instance, metatype } = wrapper;
  if (!instance || !metatype) continue;
  const base = Reflect.getMetadata(PATH_METADATA, metatype);
  for (const name of scanner.getAllMethodNames(Object.getPrototypeOf(instance))) {
    const handler = instance[name];
    const path = Reflect.getMetadata(PATH_METADATA, handler);
    if (path === undefined) continue;
    const method = RequestMethod[Reflect.getMetadata(METHOD_METADATA, handler)];
    const isPublic = reflector.getAllAndOverride(IS_PUBLIC_KEY, [handler, metatype]) ?? false;
    const roles = reflector.getAllAndOverride(ROLES_KEY, [handler, metatype]) ?? [];
    console.log([method, `/${base}/${path}`, isPublic ? 'PUBLIC' : 'AUTH', roles.join('|') || '-'].join('\t'));
  }
}
await app.close();

출력된 표를 놓고 아래 기준으로 검토합니다.

  • PUBLIC 라우트가 모두 의도한 목록(로그인, 회원가입, 헬스체크, 공개 조회)에 해당하는가
  • POST·PATCH·DELETE 중 PUBLIC인 것이 있다면 그 이유를 설명할 수 있는가
  • /admin 경로에 역할 지정(-)이 빠진 라우트가 없는가
  • 컨트롤러 클래스에 @Public()이 붙어 있지 않은가
  • 모든 @Body()·@Query() 타입이 any나 인터페이스가 아닌 DTO 클래스인가
  • 사용자·결제 관련 응답이 응답 DTO를 거쳐 나가는가
  • 운영 빌드에서 /docs, /api-json 같은 문서 경로가 404를 반환하는가

이 스크립트를 CI에서 실행해 결과를 파일로 남기면, PR마다 공개 라우트가 새로 생겼는지 diff로 확인할 수 있습니다. 공개 라우트 목록을 스냅샷 테스트로 고정해 두는 방식도 효과적입니다.

자주 묻는 질문

전역 Guard를 걸면 성능이나 헬스체크에 문제가 생기지 않나요?

Guard는 요청마다 메타데이터를 한 번 읽고 토큰을 검증하는 정도라 부담이 크지 않습니다. 로드밸런서 헬스체크 경로에는 메서드 단위로 @Public()을 붙이면 되고, 응답에는 버전·DB 상태 같은 상세 정보를 넣지 않는 편이 좋습니다.

whitelist만 켜고 forbidNonWhitelisted는 꺼도 되나요?

whitelist만으로도 정의되지 않은 필드는 제거됩니다. 다만 forbidNonWhitelisted를 켜면 클라이언트가 잘못된 필드를 보냈다는 사실이 400 오류로 드러나 버그를 빨리 찾을 수 있습니다. 구버전 앱이 여분 필드를 보내는 상황이 있다면 그 기간에만 whitelist만 쓰는 식으로 운영하세요.

GraphQL 리졸버에도 같은 기준이 적용되나요?

전역 Guard와 ValidationPipe는 GraphQL에도 적용할 수 있지만, Guard 안에서 요청 객체를 꺼내는 방식이 HTTP와 다릅니다(GqlExecutionContext 사용). 쿼리 깊이·별칭 같은 GraphQL 고유 위험은 GraphQL 보안 가이드에서 따로 점검하세요.

정리

  • NestJS는 Guard가 없으면 열리는 구조이므로, APP_GUARD로 전역 인증을 걸고 @Public()은 메서드 단위로만 씁니다. 관리자 기능은 역할 지정이 없으면 거부되도록 만듭니다.
  • 전역 ValidationPipe에는 whitelist·forbidNonWhitelisted·transform을 켜고, 저장할 필드는 서비스에서 명시적으로 고릅니다.
  • 예외 필터는 상세 오류를 로그로만 보내고, 응답은 응답 DTO로 필요한 필드만 내보내며, Swagger는 운영 환경에서 끕니다.
  • 라우트 목록을 뽑아 공개 여부와 역할을 대조하는 점검을 CI에 넣어 두면 새 컨트롤러가 추가돼도 누락을 빨리 찾을 수 있습니다. 직접 점검하기 어렵다면 무료 분석 신청으로 점검을 받아볼 수 있습니다.

참고 자료

함께 읽으면 좋은 글