icon
10장 : 보안과 모범 사례

CORS, CSRF, XSS 대응


9장에서는 NestJS 애플리케이션의 성능을 최적화하고 스케일링하는 다양한 전략과 함께, 시스템의 건강 상태를 지속적으로 파악하기 위한 모니터링 및 프로파일링 기법에 대해 알아보았습니다. 이제 10장에서는 애플리케이션의 견고함과 신뢰성을 보장하는 데 가장 중요한 요소 중 하나인 보안(Security) 에 대해 다루며, 그 첫 번째 주제로 웹 애플리케이션에서 흔히 발생하는 공격인 CORS, CSRF, XSS에 대한 대응 방법을 NestJS 환경을 중심으로 살펴보겠습니다.

현대의 웹 애플리케이션은 사용자 데이터와 민감한 정보를 다루므로 보안에 대한 깊은 이해와 적절한 방어책 마련이 필수적입니다. 단 한 번의 보안 취약점도 사용자 신뢰 상실, 데이터 유출, 금전적 손실 등 치명적인 결과를 초래할 수 있습니다.


웹 보안의 중요성

웹 애플리케이션 보안은 사용자의 정보를 보호하고, 서비스의 무결성을 유지하며, 비즈니스 연속성을 보장하는 데 결정적인 역할을 합니다. 개발자는 항상 잠재적인 위협을 인지하고, 이를 방어하기 위한 모범 사례를 적용해야 합니다. OWASP Top 10과 같은 보안 취약점 목록을 참고하여 주요 공격 유형과 대응 방안을 숙지하는 것이 중요합니다.

이 절에서는 특히 프론트엔드와 백엔드 간의 상호작용에서 발생하는 대표적인 보안 문제 세 가지, 즉 CORS, CSRF, XSS에 대해 집중적으로 다룹니다.


CORS 대응

CORS란?

CORS(Cross-Origin Resource Sharing) 는 웹 페이지의 제한된 리소스가 최초 그 리소스를 제공한 도메인 밖의 다른 도메인으로부터 요청될 때, 그 요청을 허용할 것인지 말 것인지를 브라우저가 결정하는 보안 메커니즘입니다. 웹 브라우저는 동일 출처 정책(Same-Origin Policy, SOP) 에 따라, 다른 출처(Origin)로부터의 HTTP 요청을 기본적으로 제한합니다. 여기서 '출처'는 프로토콜(http/https), 호스트(도메인), 포트 번호가 모두 같아야 동일한 출처로 간주됩니다.

왜 필요한가?: 악의적인 웹 사이트가 사용자의 동의 없이 다른 사이트의 API를 호출하여 민감한 정보를 빼내거나, 사용자 세션을 이용하는 것을 방지하기 위함입니다.

어떤 상황에서 발생하는가?: 프론트엔드 애플리케이션(예: http://localhost:3001에서 실행되는 React 앱)이 백엔드 API(예: http://localhost:3000에서 실행되는 NestJS 앱)에 요청을 보낼 때, 출처가 다르므로 CORS 문제가 발생합니다.

NestJS에서 CORS 대응

NestJS는 cors 패키지를 사용하여 CORS를 쉽게 설정할 수 있습니다.

단계 1: main.ts에 CORS 활성화

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common'; // DTO 유효성 검사를 위한 ValidationPipe

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

  // 전역 ValidationPipe 적용 (선택 사항이지만 모범 사례)
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true, // DTO에 정의되지 않은 속성은 자동 제거
    forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있을 경우 요청 거부
    transform: true, // DTO 타입 변환 활성화
  }));

  // CORS 활성화
  // 옵션 없이 호출하면 모든 출처(*)에서 요청을 허용합니다 (개발 환경에서 유용).
  app.enableCors();

  // 특정 출처만 허용하는 경우:
  /*
  app.enableCors({
    origin: 'http://localhost:3001', // 허용할 프론트엔드 도메인
    methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', // 허용할 HTTP 메서드
    credentials: true, // 자격 증명(쿠키, HTTP 인증 헤더 등)을 함께 보낼 수 있도록 허용
    allowedHeaders: 'Content-Type, Accept, Authorization', // 허용할 요청 헤더
  });
  */

  await app.listen(3000);
}
bootstrap();
  • app.enableCors(): 인자 없이 호출하면 모든 출처(*)에서 모든 HTTP 메서드를 허용하는 가장 개방적인 설정이 됩니다. 개발 단계에서 편리하지만, 운영 환경에서는 반드시 특정 출처와 메서드만 허용하도록 제한해야 합니다.
  • origin: CORS를 허용할 클라이언트의 출처를 지정합니다. 배열로 여러 출처를 지정할 수도 있습니다. (예: ['http://localhost:3001', 'https://your-frontend-domain.com'])
  • methods: 허용할 HTTP 메서드를 지정합니다.
  • credentials: 클라이언트가 자격 증명(쿠키, HTTP 인증 헤더 등)을 함께 보내는 것을 허용할지 여부를 설정합니다. true로 설정 시 origin*를 사용할 수 없으므로 특정 출처를 명시해야 합니다.
  • allowedHeaders: 클라이언트가 요청에 포함할 수 있는 헤더를 지정합니다.

운영 환경에서의 CORS 설정 모범 사례

운영 환경에서는 origin을 명시적으로 제한하고, credentials 옵션 사용 시 주의하며, allowedHeadersexposedHeaders 등을 필요에 따라 설정해야 합니다.

// src/main.ts (운영 환경 CORS 예시)
// ...
const app = await NestFactory.create(AppModule);

const whitelist = ['https://your-production-frontend.com', 'https://your-staging-frontend.com']; // 운영 환경 프론트엔드 도메인
app.enableCors({
  origin: function (origin, callback) {
    if (!origin || whitelist.indexOf(origin) !== -1) { // 요청의 origin이 whitelist에 있거나 origin이 없는 경우 (예: Postman 요청)
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
  credentials: true,
  // allowedHeaders: 'Content-Type, Accept, Authorization', // 필요한 경우 명시
});
// ...

CSRF 대응

CSRF란?

CSRF(Cross-Site Request Forgery) 는 공격자가 사용자의 웹 브라우저가 특정 웹 사이트에 보낸 요청을 위조하여 사용자의 의지와 상관없이 특정 작업을 수행하게 만드는 공격입니다. 이는 사용자가 이미 해당 웹 사이트에 로그인되어 있고, 브라우저가 세션 쿠키를 자동으로 포함하여 요청을 보내는 취약점을 이용합니다.

공격 시나리오

사용자가 은행 웹사이트(A)에 로그인합니다.

사용자가 악성 웹사이트(B)를 방문합니다.

악성 웹사이트(B)는 사용자 모르게 은행 웹사이트(A)로 계좌 이체 요청을 보내는 숨겨진 폼(또는 이미지, 스크립트)을 포함합니다.

브라우저는 은행 웹사이트(A)의 쿠키를 자동으로 첨부하여 요청을 보내고, 은행 서버는 사용자의 정당한 요청으로 오인하여 작업을 처리합니다.

왜 어려운가?: 공격자가 서버에 직접 접근하는 것이 아니라, 사용자의 브라우저를 통해 정상적인 요청처럼 보이게 만들기 때문에 방어가 까다롭습니다.

NestJS에서 CSRF 대응

CSRF 공격을 방어하는 가장 일반적이고 효과적인 방법은 CSRF 토큰(CSRF Token) 을 사용하는 것입니다.

CSRF 토큰 작동 방식

서버는 클라이언트에게 고유하고 예측 불가능한 CSRF 토큰을 발급합니다 (세션별로 또는 요청별로).

클라이언트는 폼 제출이나 AJAX 요청 시 이 토큰을 요청 헤더나 폼 데이터에 포함하여 서버로 보냅니다.

서버는 전송받은 토큰과 자신이 발급한 토큰을 비교하여 일치하는 경우에만 요청을 처리합니다. 악성 웹사이트는 이 토큰 값을 알 수 없으므로 위조된 요청에 토큰을 포함시킬 수 없습니다.

NestJS 구현 (with csurf 패키지)

csurf는 Express 미들웨어로, CSRF 토큰을 관리하는 데 사용됩니다. NestJS는 Express를 기반으로 하므로 쉽게 통합할 수 있습니다.

단계 1: 필요한 패키지 설치

npm install csurf cookie-parser
npm install --save-dev @types/csurf @types/cookie-parser
  • csurf: CSRF 토큰 미들웨어.
  • cookie-parser: CSRF 토큰을 쿠키에 저장하기 위해 필요.

단계 2: main.ts에 미들웨어 적용

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser'; // cookie-parser 임포트
import * as csurf from 'csurf'; // csurf 임포트

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

  app.use(cookieParser()); // cookie-parser 미들웨어 적용
  app.use(csurf({ cookie: true })); // csurf 미들웨어 적용 (쿠키 기반 토큰 저장)

  // 모든 요청에 대해 CSRF 토큰을 응답 헤더나 쿠키에 설정합니다.
  // 이 토큰은 프론트엔드가 다음 요청 시 포함해야 합니다.
  app.use((req, res, next) => {
    // res.cookie('XSRF-TOKEN', req.csrfToken()); // 쿠키에 저장하는 방식 (Angular 등에서 사용)
    res.locals.csrfToken = req.csrfToken(); // 템플릿 엔진에서 사용하거나, 필요시 특정 엔드포인트에서 반환
    next();
  });

  await app.listen(3000);
}
bootstrap();
  • app.use(cookieParser()): csurf가 쿠키를 사용하도록 cookie-parser를 먼저 적용합니다.
  • app.use(csurf({ cookie: true })): csurf 미들웨어를 적용합니다. cookie: true 옵션은 CSRF 토큰을 암호화된 쿠키에 저장하도록 지시합니다.
  • req.csrfToken(): 이 함수는 요청별로 고유한 CSRF 토큰을 생성합니다.
  • res.locals.csrfToken = req.csrfToken(): 생성된 토큰을 res.locals에 저장하여 템플릿 엔진에서 접근하거나, 프론트엔드가 요청할 때 특정 API 엔드포인트를 통해 토큰을 제공할 수 있습니다.

프론트엔드에서의 CSRF 토큰 처리 (예시)

프론트엔드는 서버로부터 받은 CSRF 토큰을 모든 POST, PUT, PATCH, DELETE 요청 시 X-CSRF-Token (또는 다른 이름) 헤더에 포함하여 보내야 합니다.

// 프론트엔드 (JavaScript Fetch API 예시)
// 1. 초기 페이지 로딩 시 또는 특정 엔드포인트 호출을 통해 CSRF 토큰을 가져옵니다.
//    (서버에서 <meta name="csrf-token" content="<%= csrfToken %>"> 등으로 렌더링하거나,
//     /csrf-token 엔드포인트를 통해 JSON으로 반환받을 수 있습니다.)
let csrfToken = '서버로부터 받은 토큰'; // 예시

async function sendPostRequest() {
  const response = await fetch('/api/data', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken, // CSRF 토큰을 헤더에 포함
    },
    body: JSON.stringify({ item: 'new item' }),
  });
  const data = await response.json();
  console.log(data);
}

주의사항

  • GET 요청은 CSRF 공격에 취약하지 않으므로 CSRF 토큰 검사를 적용하지 않습니다. (멱등성 Idempotence)
  • REST API의 경우, 서버에서 토큰을 생성하여 특정 GET 요청(예: /csrf-token)으로 프론트엔드에 전달하고, 프론트엔드가 이후 모든 상태 변경 요청에 토큰을 포함시키는 방식이 일반적입니다.

XSS 대응

XSS란?

XSS(Cross-Site Scripting) 는 공격자가 웹 애플리케이션에 악성 스크립트를 삽입하여 사용자 브라우저에서 실행되도록 하는 공격입니다. 삽입된 스크립트는 사용자의 세션 쿠키 탈취, 개인 정보 유출, 페이지 변조 등 다양한 악성 행위를 수행할 수 있습니다.

공격 시나리오

공격자가 게시판이나 댓글 입력 폼에 <script>alert('공격!');</script> 와 같은 악성 스크립트를 포함한 글을 작성하여 저장합니다.

다른 사용자가 해당 글을 조회하는 페이지를 방문합니다.

웹 페이지가 공격자가 삽입한 스크립트를 필터링 없이 그대로 렌더링하여 사용자 브라우저에서 실행됩니다.

실행된 스크립트가 사용자의 세션 쿠키를 공격자에게 전송하거나, 피싱 페이지로 리다이렉트하는 등의 악성 행위를 수행합니다.

XSS의 종류

  • 저장형 XSS (Stored XSS): 악성 스크립트가 서버에 저장되었다가 사용자에게 제공될 때 실행됩니다. 가장 위험합니다.
  • 반사형 XSS (Reflected XSS): 악성 스크립트가 URL 파라미터 등을 통해 서버로 전달되어, 서버가 이를 응답에 포함하여 사용자에게 '반사'될 때 실행됩니다.
  • DOM 기반 XSS (DOM-based XSS): 서버를 거치지 않고 클라이언트 측 JavaScript 코드에 의해 DOM이 조작되면서 악성 스크립트가 실행됩니다.

NestJS (백엔드)에서 XSS 대응

XSS 공격의 핵심은 사용자 입력을 신뢰하지 않고, 출력 시 적절하게 이스케이프(Escape)하는 것입니다. 백엔드는 주로 데이터 저장 및 조회 시 방어 역할을 합니다.

대응 전략

입력값 검증 및 살균(Sanitization)

  • 사용자로부터 입력받는 모든 데이터에 대해 유효성 검사를 수행하고, 잠재적으로 위험한 문자(HTML 태그, JavaScript 코드 등)를 제거하거나 변환합니다.
  • NestJS에서 DTO(Data Transfer Object)와 @nestjs/class-validator, @nestjs/class-transformer를 사용하는 것은 유효성 검사에 매우 효과적입니다.
  • DOMPurify, sanitize-html과 같은 라이브러리를 사용하여 HTML 내용을 살균할 수 있습니다.
npm install sanitize-html
npm install --save-dev @types/sanitize-html
// src/comment/comment.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';
import * as sanitizeHtml from 'sanitize-html';

export class CreateCommentDto {
  @IsString()
  @IsNotEmpty()
  // Setter를 사용하여 입력 시 HTML 살균
  set content(value: string) {
    this._content = sanitizeHtml(value, {
      allowedTags: [], // 모든 HTML 태그 제거
      allowedAttributes: {}, // 모든 속성 제거
    });
  }
  get content(): string {
    return this._content;
  }
  private _content: string;

  @IsString()
  @IsNotEmpty()
  author: string;
}
  • DTO의 setter를 활용하여 sanitize-html 라이브러리로 content 필드의 HTML 태그를 모두 제거했습니다. 이는 저장형 XSS를 방어하는 데 중요합니다.

출력 시 이스케이프(Escaping)

  • 가장 중요하고 기본적인 방어책입니다. 데이터베이스에서 가져온 데이터를 HTML 페이지에 렌더링하기 전에, 모든 사용자 생성 콘텐츠에 대해 HTML 엔티티(HTML entities)로 변환합니다.
  • <&lt;, >&gt;, &&amp;, "&quot;, '&#x27; 등으로 변환하여 브라우저가 스크립트 코드를 실행 가능한 HTML로 해석하지 못하도록 합니다.
  • NestJS 백엔드가 주로 JSON API를 제공한다면, 이스케이핑은 클라이언트(프론트엔드)의 역할이 됩니다. 프론트엔드 프레임워크(React, Angular, Vue 등)는 기본적으로 XSS 방어 메커니즘을 내장하고 있어, 텍스트를 DOM에 삽입할 때 자동으로 이스케이프합니다. (예: React의 JSX는 기본적으로 문자열을 이스케이프함)
  • 하지만 백엔드에서 SSR(Server-Side Rendering)을 하거나 HTML을 직접 생성하는 경우, 템플릿 엔진(Handlebars, Pug 등)이 안전한 이스케이핑을 제공하는지 확인하고 활용해야 합니다.

HTTP Only 쿠키 사용

  • 세션 ID와 같은 민감한 정보가 담긴 쿠키에는 HttpOnly 플래그를 설정합니다. 이는 클라이언트 측 JavaScript가 해당 쿠키에 접근하는 것을 막아 XSS 공격으로 인한 쿠키 탈취를 방지합니다. NestJS에서 쿠키를 설정할 때 옵션을 추가합니다.
// src/auth/auth.controller.ts (예시)
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';

@Controller('auth')
export class AuthController {
  @Get('login')
  login(@Res() res: Response) {
    // ... 로그인 로직
    res.cookie('session_id', 'some_secure_session_id', {
      httpOnly: true, // JavaScript 접근 불가
      secure: process.env.NODE_ENV === 'production', // HTTPS에서만 전송
      maxAge: 3600000, // 1시간
    });
    res.send('Logged in');
  }
}

CSP (Content Security Policy) 헤더 설정

  • CSP는 브라우저에게 어떤 리소스(스크립트, 스타일시트, 이미지 등)를 로드할 수 있는지 알려주는 HTTP 응답 헤더입니다. 신뢰할 수 있는 출처에서만 리소스 로드를 허용하여 XSS 공격을 포함한 다양한 인젝션 공격의 위험을 줄입니다.
// src/main.ts (CSP 미들웨어 적용 예시 - helmet 사용)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet'; // helmet 패키지 설치: npm install helmet

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

  app.use(helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"], // 기본적으로 자신의 출처만 허용
        scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // 스크립트 출처 (운영에서는 'unsafe-inline' 제거 권장)
        imgSrc: ["'self'", "data:", "*.example.com"], // 이미지 출처
        // ... 기타 디렉티브 설정
      },
    },
  }));
  // ...
  await app.listen(3000);
}
bootstrap();
  • helmet 패키지는 다양한 보안 HTTP 헤더를 쉽게 설정할 수 있도록 돕습니다. CSP는 이 중 하나입니다.
  • CSP는 매우 강력하지만, 복잡하여 애플리케이션에 맞게 세밀하게 조정해야 합니다. 잘못 설정하면 정당한 리소스 로드도 차단할 수 있습니다.

CORS, CSRF, XSS는 웹 애플리케이션 개발에서 항상 고려해야 할 기본적인 보안 취약점입니다. NestJS는 Express 기반이므로 기존 Node.js 및 Express 생태계의 강력한 보안 미들웨어와 라이브러리(예: cors, csurf, helmet)를 쉽게 통합하여 이러한 공격에 효과적으로 대응할 수 있습니다. 보안은 단일 솔루션으로 해결되는 것이 아니라, 다중 방어(Defense in Depth) 원칙에 따라 여러 계층에서 방어책을 마련하는 지속적인 노력임을 명심해야 합니다.