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 Limiting과 NestJS OAuth2 PKCE 인증 심화도 함께 참고하세요.