NestJS Helmet·CORS 보안 설정

NestJS HTTP 보안이 중요한 이유

NestJS 앱을 배포할 때 비즈니스 로직과 인증에만 집중하고 HTTP 보안 헤더를 무시하는 경우가 많습니다. 하지만 XSS, 클릭재킹, MIME 스니핑 같은 공격은 보안 헤더 몇 줄로 차단할 수 있습니다. Helmet과 CORS를 올바르게 설정하면 OWASP Top 10의 상당 부분을 방어할 수 있습니다.

이 글에서는 Helmet 미들웨어의 각 헤더별 동작, CORS 세밀한 제어, Content Security Policy(CSP) 설계, 그리고 프로덕션 보안 체크리스트까지 심층적으로 다룹니다.

Helmet: 보안 헤더 자동 설정

Helmet은 Express/Fastify 미들웨어로, 한 줄 설정으로 11개 보안 헤더를 자동 추가합니다:

npm install helmet

// main.ts
import helmet from 'helmet';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 기본 설정 — 11개 보안 헤더 활성화
  app.use(helmet());

  await app.listen(3000);
}

Helmet이 설정하는 주요 헤더들:

헤더 기본값 방어 대상
X-Content-Type-Options nosniff MIME 스니핑 공격
X-Frame-Options SAMEORIGIN 클릭재킹
Strict-Transport-Security max-age=15552000 HTTP 다운그레이드 공격
X-DNS-Prefetch-Control off DNS 프리페치 프라이버시 유출
X-Download-Options noopen IE 다운로드 자동 실행
X-Permitted-Cross-Domain-Policies none Flash/PDF 크로스도메인 접근

Helmet 세부 설정 커스터마이징

기본 설정이 모든 상황에 맞지는 않습니다. API 서버와 웹 앱에 따라 조정이 필요합니다:

// API 서버용 Helmet 설정
app.use(helmet({
  // CSP: API 서버는 HTML을 반환하지 않으므로 비활성화 가능
  contentSecurityPolicy: false,

  // HSTS: HTTPS 강제 (프록시 뒤에서는 주의)
  hsts: {
    maxAge: 31536000,        // 1년
    includeSubDomains: true,
    preload: true,           // HSTS Preload 목록 등록
  },

  // Referrer-Policy
  referrerPolicy: {
    policy: 'strict-origin-when-cross-origin',
  },

  // 클릭재킹 방지
  frameguard: {
    action: 'deny',  // iframe 완전 차단 (API 서버)
  },
}));

// 웹 앱용 Helmet 설정 (CSP 포함)
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net"],
      styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com"],
      imgSrc: ["'self'", "data:", "*.amazonaws.com"],
      fontSrc: ["'self'", "fonts.gstatic.com"],
      connectSrc: ["'self'", "api.myapp.com", "*.sentry.io"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      upgradeInsecureRequests: [],
    },
  },
}));

CORS 세밀한 제어

NestJS의 CORS 설정을 enableCors(true)로 전체 허용하면 보안 취약점이 됩니다. 프로덕션에서는 반드시 세밀하게 제어하세요:

// main.ts — 프로덕션 CORS 설정
app.enableCors({
  // 허용 오리진: 문자열, 배열, 정규식, 함수 모두 가능
  origin: (origin, callback) => {
    const allowedOrigins = [
      'https://myapp.com',
      'https://admin.myapp.com',
      'https://staging.myapp.com',
    ];

    // 서버 간 요청 (origin 없음) 또는 허용 목록
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error(`CORS blocked: ${origin}`));
    }
  },

  // 허용 HTTP 메서드
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],

  // 허용 요청 헤더
  allowedHeaders: [
    'Content-Type',
    'Authorization',
    'X-Request-ID',
    'X-API-Key',
  ],

  // 클라이언트에 노출할 응답 헤더
  exposedHeaders: [
    'X-Total-Count',
    'X-Request-ID',
    'Link',              // 페이지네이션
  ],

  // 쿠키/인증 정보 허용
  credentials: true,

  // Preflight 캐시 (초)
  maxAge: 7200,  // 2시간 — OPTIONS 요청 줄이기

  // 204 대신 200 응답 (일부 레거시 브라우저 호환)
  optionsSuccessStatus: 200,
});

컨트롤러별 CORS 오버라이드

전역 CORS 외에 특정 컨트롤러나 라우트에 다른 CORS 정책을 적용할 수 있습니다:

// 공개 API — 모든 오리진 허용
@Controller('public')
export class PublicController {

  @Get('health')
  @Header('Access-Control-Allow-Origin', '*')
  healthCheck() {
    return { status: 'ok' };
  }
}

// Webhook 수신 — 특정 오리진만
@Controller('webhooks')
export class WebhookController {

  @Post('stripe')
  @Header('Access-Control-Allow-Origin', 'https://api.stripe.com')
  handleStripeWebhook(@Body() body: any) {
    // Stripe 웹훅 처리
  }
}

CSP Nonce 동적 생성

서버 사이드 렌더링(SSR)에서 인라인 스크립트를 안전하게 허용하려면 CSP nonce를 사용합니다:

import { randomBytes } from 'crypto';

// CSP Nonce 미들웨어
@Injectable()
export class CspNonceMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    // 요청마다 고유 nonce 생성
    const nonce = randomBytes(16).toString('base64');
    res.locals.cspNonce = nonce;

    // CSP 헤더에 nonce 포함
    res.setHeader('Content-Security-Policy',
      `default-src 'self'; ` +
      `script-src 'self' 'nonce-${nonce}'; ` +
      `style-src 'self' 'unsafe-inline';`
    );

    next();
  }
}

// 템플릿에서 사용
// <script nonce="<%= cspNonce %>">
//   // 이 스크립트만 실행 허용
// </script>

보안 헤더 검증 테스트

설정이 올바른지 자동 테스트로 검증합니다:

// security-headers.e2e-spec.ts
describe('Security Headers', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();
    app = module.createNestApplication();
    app.use(helmet());
    app.enableCors({ origin: 'https://myapp.com', credentials: true });
    await app.init();
  });

  it('should set X-Content-Type-Options', () => {
    return request(app.getHttpServer())
      .get('/health')
      .expect(header => {
        expect(header['x-content-type-options']).toBe('nosniff');
      });
  });

  it('should set HSTS header', () => {
    return request(app.getHttpServer())
      .get('/health')
      .expect(header => {
        expect(header['strict-transport-security']).toContain('max-age=');
      });
  });

  it('should block disallowed CORS origins', () => {
    return request(app.getHttpServer())
      .options('/api/users')
      .set('Origin', 'https://evil.com')
      .expect(res => {
        expect(res.headers['access-control-allow-origin']).toBeUndefined();
      });
  });

  it('should allow configured CORS origin', () => {
    return request(app.getHttpServer())
      .options('/api/users')
      .set('Origin', 'https://myapp.com')
      .set('Access-Control-Request-Method', 'GET')
      .expect('access-control-allow-origin', 'https://myapp.com')
      .expect('access-control-allow-credentials', 'true');
  });
});

Fastify 어댑터 보안 설정

Fastify를 사용할 때는 Helmet 패키지가 다릅니다:

npm install @fastify/helmet @fastify/cors

// main.ts — Fastify 어댑터
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule, new FastifyAdapter(),
  );

  // Fastify 전용 Helmet
  await app.register(require('@fastify/helmet'), {
    contentSecurityPolicy: false,
  });

  // Fastify 전용 CORS
  await app.register(require('@fastify/cors'), {
    origin: ['https://myapp.com'],
    credentials: true,
    maxAge: 7200,
  });

  await app.listen(3000, '0.0.0.0');
}

프로덕션 보안 체크리스트

항목 설정 확인 방법
Helmet 활성화 app.use(helmet()) 응답 헤더에 X-Content-Type-Options 존재
CORS 오리진 제한 허용 도메인 명시 다른 오리진에서 fetch 실패
HSTS 활성화 max-age ≥ 1년 curl -I로 Strict-Transport-Security 확인
X-Powered-By 제거 Helmet 자동 처리 응답에 X-Powered-By 없음
Rate Limiting ThrottlerModule 적용 과도한 요청 시 429 응답
보안 스캔 securityheaders.com A+ 등급 목표
# 보안 헤더 점검 명령어
curl -I https://api.myapp.com/health

# 기대 결과:
# X-Content-Type-Options: nosniff
# X-Frame-Options: SAMEORIGIN
# Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
# Content-Security-Policy: default-src 'self'
# X-DNS-Prefetch-Control: off
# (X-Powered-By 헤더 없음)

마무리

Helmet과 CORS는 NestJS 보안의 첫 번째 방어선입니다. Helmet 한 줄로 11개 보안 헤더를 추가하고, CORS를 허용 오리진·메서드·헤더 단위로 세밀하게 제어하면 대부분의 일반적인 웹 공격을 차단할 수 있습니다. 특히 enableCors(true)로 전체 허용하는 실수는 프로덕션에서 반드시 피해야 합니다.

관련 글로 NestJS Throttler Rate LimitingNestJS OAuth2 PKCE 인증 심화도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux