본문 바로가기

CORS, CSRF, XSS 대응

CORS·CSRF·XSS가 발생하는 신뢰 경계를 구분하고 출처 제한, 요청 의도 검증, 출력 컨텍스트 방어를 적용합니다.

9장에서는 NestJS 애플리케이션의 성능과 운영 상태를 다뤘습니다. 10장에서는 애플리케이션을 공격으로부터 지키는 보안을 살펴봅니다. 첫 주제는 이름이 자주 함께 나오지만 서로 다른 문제를 해결하는 CORS, CSRF, XSS입니다.

CORS, CSRF, XSS가 지키는 신뢰 경계와 Nest 적용 지점 비교

Nest · Trust boundaries

CORS·CSRF·XSS의 세 신뢰 경계

허용되지 않은 응답 읽기, 사용자 의도 없는 쓰기, 데이터의 코드 해석을 각 경계에서 막습니다. 출처 제한·요청 의도 검증·출력 처리를 따로 적용하고 실패 증거를 확인합니다.

위협별 실패 신호, Nest·렌더러 적용 지점, 성공 증거와 방어 범위
위협 방어 · 적용 지점 성공을 확인할 증거 대신하지 않는 것
CORS app.enableCors()에서 exact allowlist 또는 엄격한 predicate, 메서드, allowedHeaders, credentials, exposedHeaders를 구성합니다. Express는 cors, Fastify는 @fastify/cors를 사용합니다. 거부한 Origin은 응답을 읽지 못하고, 허용한 credentialed Origin에는 와일드카드가 아닌 해당 출처가 반환됩니다. 요청 헤더 허용과 응답 헤더 노출도 따로 검사합니다. 인증, 인가, 서버 간 접근 제어, CSRF 방어가 아닙니다.
CSRF unsafe method에서 세션에 결합된 토큰을 검증하고 Origin·Fetch Metadata·SameSite를 보조 신호로 사용합니다. Express와 Fastify는 쿠키·세션 플러그인과 보호 미들웨어의 조합·순서가 다릅니다. 유효한 세션이어도 토큰이 없거나 틀린 POST·PUT·PATCH·DELETE는 실패합니다. safe method는 상태를 바꾸지 않습니다. CORS, XSS 방어, 인증·인가를 대신하지 않습니다.
XSS HTML 텍스트·속성·URL·JavaScript마다 맞는 인코딩과 safe sink를 사용합니다. 허용하는 raw HTML은 sanitizer를 거치며 React의 dangerouslySetInnerHTML과 SSR 우회 경계를 검토합니다. 공격 payload가 코드가 아니라 데이터로 렌더링됩니다. raw HTML 허용 경로는 sanitizer 정책을 통과하고 Helmet·CSP는 추가 방어선으로 동작합니다. DTO 입력 검증, HttpOnly, CSP만으로 출력 컨텍스트 방어를 대신할 수 없습니다.
CORS · 교차 출처 응답 노출
방어 · 적용 지점
app.enableCors()에서 exact allowlist 또는 엄격한 predicate, 메서드, allowedHeaders, credentials, exposedHeaders를 구성합니다. Express는 cors, Fastify는 @fastify/cors를 사용합니다.
성공을 확인할 증거
거부한 Origin은 응답을 읽지 못하고, 허용한 credentialed Origin에는 와일드카드가 아닌 해당 출처가 반환됩니다. 요청 헤더 허용과 응답 헤더 노출도 따로 검사합니다.
대신하지 않는 것
인증, 인가, 서버 간 접근 제어, CSRF 방어가 아닙니다.
CSRF · ambient cookie가 붙은 상태 변경
방어 · 적용 지점
unsafe method에서 세션에 결합된 토큰을 검증하고 Origin·Fetch Metadata·SameSite를 보조 신호로 사용합니다. Express와 Fastify는 쿠키·세션 플러그인과 보호 미들웨어의 조합·순서가 다릅니다.
성공을 확인할 증거
유효한 세션이어도 토큰이 없거나 틀린 POST·PUT·PATCH·DELETE는 실패합니다. safe method는 상태를 바꾸지 않습니다.
대신하지 않는 것
CORS, XSS 방어, 인증·인가를 대신하지 않습니다.
XSS · 데이터가 코드로 해석되는 출력
방어 · 적용 지점
HTML 텍스트·속성·URL·JavaScript마다 맞는 인코딩과 safe sink를 사용합니다. 허용하는 raw HTML은 sanitizer를 거치며 React의 dangerouslySetInnerHTML과 SSR 우회 경계를 검토합니다.
성공을 확인할 증거
공격 payload가 코드가 아니라 데이터로 렌더링됩니다. raw HTML 허용 경로는 sanitizer 정책을 통과하고 Helmet·CSP는 추가 방어선으로 동작합니다.
대신하지 않는 것
DTO 입력 검증, HttpOnly, CSP만으로 출력 컨텍스트 방어를 대신할 수 없습니다.

실패 지점을 분리하면 운영 기준도 선명해집니다. CORS는 응답 노출, CSRF는 쓰기 의도, XSS는 렌더링 컨텍스트에서 검증합니다.

위 표는 세 위협을 한 줄씩 비교합니다. CORS는 허용하지 않은 출처가 응답을 읽는 것을, CSRF 방어는 사용자가 의도하지 않은 쓰기를, XSS 방어는 데이터가 코드로 해석되는 것을 막습니다. 각 줄에는 Nest와 렌더러에서 어디에 적용하는지, 제대로 동작한다는 것을 무엇으로 확인하는지, 그리고 그 방어가 대신하지 않는 것이 함께 적혀 있습니다. 예를 들어 CORS 줄의 마지막 칸은 CORS가 인증, 인가, 서버 사이의 접근 제어, CSRF 방어가 아니라고 말합니다.


세 방어를 먼저 구분하기

  • CORS는 브라우저가 다른 출처의 응답을 JavaScript에 보여 줘도 되는지 제어합니다.
  • CSRF 방어는 쿠키처럼 브라우저가 자동으로 붙이는 인증 정보(ambient credential)가 실린 상태 변경 요청에 사용자의 의도가 있었는지 확인합니다.
  • XSS 방어는 신뢰할 수 없는 데이터가 브라우저에서 HTML, 속성, URL, JavaScript 같은 실행 컨텍스트로 해석되지 않게 합니다.

CORS는 인증이나 인가가 아니며 CSRF 방어를 대신하지 않습니다. 입력 검증도 출력 인코딩이나 HTML 살균(sanitize)을 대신하지 않습니다. 하나의 middleware로 세 문제를 한꺼번에 해결하려 하지 말고, 각각의 지점에서 따로 실패하는지 확인해야 합니다.


CORS: 다른 출처의 응답을 읽어도 되는가

출처(origin)는 스킴, 호스트, 포트의 조합입니다. 동일 출처 정책은 다른 출처의 응답을 브라우저 JavaScript가 마음대로 읽지 못하게 하고, CORS 응답 헤더는 이 제한을 선택적으로 풀어 줍니다. 동일 출처 정책과 단순 요청·사전 요청의 기본은 캐시와 CORS에서 먼저 볼 수 있습니다.

중요한 점은 CORS가 서버로 향하는 모든 교차 사이트 요청을 막는 방화벽이 아니라는 것입니다. 브라우저는 단순 요청(simple request)을 서버에 보낸 뒤 응답 읽기만 막을 수 있으므로, 서버는 모든 요청에 인증과 인가를 따로 수행해야 합니다.

Nest에서 출처를 명시적으로 허용하기

Nest는 Express 어댑터에서 cors 패키지를, Fastify 어댑터에서 @fastify/cors 패키지를 씁니다. 두 경우 모두 app.enableCors()로 설정하지만, 지원하는 옵션과 기본값은 어댑터마다 다릅니다.

운영 환경에서는 문자열 전체가 정확히 일치하는 출처 목록이나, 그만큼 엄격한 판별 함수를 씁니다. 요청의 Origin 값을 검사 없이 그대로 돌려보내서는 안 됩니다.

src/main.ts
const allowedOrigins = new Set([
  'https://app.example.com',
  'https://admin.example.com',
]);

app.enableCors({
  origin(origin, callback) {
    // Origin이 없으면 CORS가 적용되는 교차 출처 브라우저 요청이 아니다.
    // 이 분기는 인증·인가를 생략한다는 뜻이 아니다.
    if (origin === undefined || allowedOrigins.has(origin)) {
      callback(null, true);
      return;
    }

    callback(new Error('Origin is not allowed by CORS'), false);
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  credentials: true,
  allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
  exposedHeaders: ['X-Request-Id'],
});
  • allowedHeaders: 브라우저가 실제 요청에 붙여 보낼 수 있도록 preflight에서 허용할 요청 헤더입니다.
  • exposedHeaders: 응답을 받은 JavaScript가 읽을 수 있도록 추가로 공개할 응답 헤더입니다.
  • 인증 정보가 실린 CORS 응답에는 Access-Control-Allow-Origin: *를 쓸 수 없으며, 허용한 구체적인 출처를 돌려줘야 합니다.
  • 요청의 출처에 따라 응답이 달라지면, 캐시가 출처별 응답을 섞지 않도록 Vary: Origin도 확인합니다.
  • Nest 공식 CORS 문서에 따르면 Express의 cors는 기본으로 PUT, PATCH, DELETE까지 허용하지만, Fastify의 @fastify/cors는 GET, HEAD, POST만 허용합니다. 다른 메서드가 필요하면 위처럼 methods를 명시합니다.

CORS를 허용했다는 것은 “이 브라우저 JavaScript가 응답을 읽어도 된다”는 뜻일 뿐, 요청한 사람이 누구인지나 그 작업을 할 권한이 있는지를 증명하지 않습니다.

더 깊이: JSON API가 OPTIONS 요청을 한 번 더 받는 이유

브라우저는 단순 요청의 조건을 벗어난 교차 출처 요청을 보내기 전에 OPTIONS 메서드로 사전 요청(preflight)을 보냅니다. MDN의 CORS 문서에 따르면 단순 요청은 메서드가 GET, HEAD, POST 가운데 하나이고, 직접 설정한 헤더가 Accept나 Content-Type 같은 CORS 안전 목록에만 있으며, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain 가운데 하나여야 합니다. 그래서 Content-Type: application/json인 POST나 Authorization 헤더를 붙인 요청은 모두 사전 요청을 거치며, 그만큼 왕복이 하나 더 생깁니다.

사전 요청의 결과는 Access-Control-Max-Age 응답 헤더로 정한 시간 동안 브라우저에 캐시됩니다. 이 헤더가 없으면 기본값은 5초이고, 브라우저마다 상한도 있어서 Chromium 76 이후는 2시간, Firefox는 24시간까지만 받아들입니다. Express 어댑터가 쓰는 cors 패키지는 maxAge 옵션에 정수를 줄 때만 이 헤더를 보내므로, 사전 요청이 지연의 원인으로 보인다면 enableCors()의 maxAge를 정합니다. 다만 캐시 시간이 길수록 허용 메서드나 헤더를 바꿨을 때 브라우저에 반영되는 시간도 늦어집니다.


CSRF: 자동으로 실린 인증 정보로 보낸 쓰기 요청

브라우저는 쿠키의 Domain, Path, Secure, SameSite 조건이 맞으면 요청에 쿠키를 자동으로 붙입니다. 공격자는 이 성질을 이용해, 사용자가 다른 사이트를 보는 동안 의도하지 않은 상태 변경 요청을 보내게 만듭니다. 각 속성이 쿠키가 실리는 조건을 어떻게 바꾸는지와 SameSite가 CSRF에 주는 영향은 쿠키와 세션에서 다룹니다.

교차 출처 fetch가 쿠키를 붙일지는 클라이언트의 credentials 설정과 쿠키 속성이 정합니다. 서버의 Access-Control-Allow-Origin과 Access-Control-Allow-Credentials 응답은 그 응답을 JavaScript에 보여 주기 위한 조건일 뿐, 요청을 보낼지 말지를 정하지 않습니다. 폼 제출 같은 단순 요청은 preflight 없이도 전송되므로, CORS 설정만으로는 CSRF를 막을 수 없습니다.

NestJS 12.1의 내장 CSRF 보호

Nest 공식 CSRF 문서에 따르면 NestJS 12.1부터 CSRF 보호가 내장되어, Express와 Fastify에서 똑같이 동작하고 별도 패키지나 쿠키, 세션, 클라이언트 코드 변경이 필요 없습니다. 이 기능은 브라우저가 보내는 Sec-Fetch-Site 헤더(Fetch Metadata)와 Origin 헤더로 요청이 다른 출처에서 왔는지 판단하며, Go 1.25의 CrossOriginProtection 알고리즘을 따릅니다.

  1. GET, HEAD, OPTIONS 요청은 항상 허용합니다.
  2. Sec-Fetch-Site 헤더가 있으면 same-origin과, 사용자가 직접 시작한 이동을 뜻하는 none만 허용합니다. same-site를 포함한 다른 값은 거부합니다.
  3. Sec-Fetch-Site와 Origin이 모두 없으면 브라우저가 보낸 요청이 아니므로 허용합니다.
  4. 그 밖에는 Origin 헤더의 호스트가 요청의 Host 헤더와 같아야 합니다.

거부된 요청은 ForbiddenException이 되어 403으로 응답하며, 이 검사는 라우팅보다 먼저, 즉 middleware, body 파싱, guard, interceptor, handler보다 먼저 실행됩니다. NestFactory.create() 바로 뒤에서 켭니다.

src/main.ts (부분 발췌)
const app = await NestFactory.create(AppModule);

app.enableCsrfProtection({
  // 다른 출처의 프런트엔드가 쿠키를 실어 쓰기 요청을 보낸다면 여기에 적습니다.
  trustedOrigins: ['https://app.example.com', 'https://admin.example.com'],
});

공식 문서는 CORS로 허용한 출처가 자동으로 신뢰되지 않는다고 경고합니다. 위 CORS 예제처럼 https://app.example.com의 프런트엔드가 https://api.example.com에 쿠키를 실어 쓰기 요청을 보낸다면, 브라우저는 이 요청을 same-site로 표시하므로 trustedOrigins에 넣지 않으면 거부됩니다. 서버 사이의 요청은 보통 Sec-Fetch-Site와 Origin이 없어 설정 없이 통과하고, 다른 출처에서 오는 webhook처럼 예외가 필요한 route는 exclude로 지정합니다.

이 기능에는 한계도 있습니다. 토큰 방식이 아니라 브라우저의 헤더에 기대므로, Sec-Fetch-Site는 HTTPS나 localhost인 출처에만 전송됩니다. GET, HEAD, OPTIONS는 검사하지 않으므로 이 메서드의 handler가 상태를 바꾸면 안 됩니다. 프록시가 Host 헤더를 바꾸면, Sec-Fetch-Site를 보내지 않는 브라우저는 Origin과 Host 비교에서 403을 받으므로 프록시가 Host를 보존하게 하거나 공개 출처를 trustedOrigins에 넣습니다. WebSocket 업그레이드 요청도 검사하지 않습니다. 또 이 보호 뒤에 CORS를 등록하면 거부된 403 응답에는 CORS 헤더가 붙지 않아, 다른 출처의 프런트엔드는 응답 본문 대신 네트워크 오류를 보게 됩니다.

토큰 방식이 필요할 때

2023년 이후의 최신 브라우저는 모두 Sec-Fetch-Site를 보냅니다. 두 헤더를 모두 보내지 않는 아주 오래된 브라우저는 브라우저가 아닌 클라이언트처럼 보여 통과하므로, 이런 사용자까지 보호해야 한다면 토큰 방식을 더합니다. 서버가 세션에 묶이고 예측할 수 없는 CSRF 토큰을 발급하고, 클라이언트는 상태를 바꾸는 요청의 폼 필드나 사용자 정의 헤더에 그 토큰을 담아 보내며, 서버는 토큰을 검증한 뒤에만 작업을 수행합니다.

브라우저의 상태 변경 요청 (토큰 방식)
await fetch('https://api.example.com/api/items', {
  method: 'POST',
  credentials: 'include',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken,
  },
  body: JSON.stringify({ name: 'new item' }),
});

토큰을 사용자 정의 헤더로 보낸다면 CORS의 allowedHeaders에도 그 헤더가 있어야 합니다. exposedHeaders에 넣는 것으로는 요청에 헤더를 실을 수 없습니다. 토큰 방식은 어댑터마다 쓰는 패키지가 다르며, 공식 문서는 Express에서 csrf-csrf 패키지를 쿠키 파서 뒤에 전역 middleware로 등록하는 방법을 안내합니다. 어느 방식이든 토큰이 없거나 틀린 쓰기 요청이 실제로 거부되는지, 그리고 응답 상태뿐 아니라 항목이 생성되지 않았는지를 테스트로 확인해야 합니다.

SameSite 쿠키도 방어를 강화하지만, 애플리케이션의 사이트 구성과 호환성을 함께 고려해야 합니다. 어떤 방식을 쓰든 GET, HEAD, OPTIONS는 서버 상태를 바꾸지 않도록 설계합니다.


XSS: 출력되는 곳에서 데이터와 코드를 분리하기

XSS는 신뢰할 수 없는 값이 브라우저에서 마크업이나 실행 코드로 해석될 때 생깁니다. 저장형, 반사형, DOM 기반 XSS는 들어오는 경로가 다르지만 방어 원칙은 같습니다.

출력 위치마다 다른 방어 쓰기

  • HTML 텍스트: 템플릿의 자동 이스케이프나 textContent처럼 데이터를 텍스트로 다루는 안전한 출력 지점을 씁니다.
  • HTML 속성: 안전한 속성만 쓰고, 값을 따옴표로 감싼 뒤 속성에 맞게 인코딩합니다.
  • URL: 허용할 스킴을 제한하고 각 쿼리 값을 URL 인코딩하며, HTML 속성에 넣을 때는 속성 처리도 함께 적용합니다.
  • JavaScript와 CSS: 신뢰할 수 없는 값을 실행 코드에 직접 끼워 넣지 않습니다. 피할 수 없다면 그 컨텍스트 전용 인코더를 씁니다.
  • 사용자가 작성한 HTML: raw HTML을 넣는 지점에 쓰기 전에, 검증된 HTML sanitizer와 명시적인 허용 정책을 적용합니다.

React는 일반 JSX의 문자열 자식과 속성 값을 이스케이프합니다. 하지만 dangerouslySetInnerHTML은 raw HTML을 그대로 넣는 지점이므로 신뢰할 수 없는 HTML을 넘기면 안 됩니다. 서버 렌더링이나 다른 템플릿 엔진도 자동 이스케이프를 건너뛰는 기능을 따로 검토해야 합니다.

Nest의 DTO와 ValidationPipe는 요청의 형태, 타입, 허용 필드를 검사하는 입력 쪽의 방어입니다. 이 검사는 값이 HTML, 속성, URL, JavaScript 가운데 어디에 출력될지 알지 못하므로 출력 인코딩을 대신하지 않습니다.

JSON API는 올바른 Content-Type: application/json으로 응답하고, 렌더러가 값을 어디에 넣는지까지 따라가 확인합니다. HttpOnly 쿠키는 JavaScript가 쿠키를 읽는 것을 막아 주지만, XSS가 사용자 대신 인증된 요청을 보내는 것까지 막지는 못합니다.

예를 들어 상품 이름으로 <b>할인</b>이라는 문자열을 받았다고 해 보겠습니다. 문자열을 허용하는 DTO를 통과했다는 사실은 이 값을 어떻게 출력할지 정해 주지 않습니다. 일반 div의 textContent에 넣으면 태그가 포함된 문자열이 그대로 화면에 나타나고, 같은 div의 innerHTML에 넣으면 b 요소로 해석됩니다. 상품 이름을 텍스트로 보여 주려는 것이라면 앞의 방법을 고릅니다. 사용자가 작성한 HTML을 지원하는 기능이라면 허용할 태그, 속성, URL을 정한 sanitizer가 필요합니다.

보안 응답 헤더와 HTTPS

보안 응답 헤더는 브라우저에게 XSS나 클릭재킹 같은 공격에 대한 보호를 켜라고 알려 줍니다. 그 가운데 CSP(Content Security Policy)는 허용된 스크립트 출처를 제한하는 추가 방어선이며, 출력 위치별 인코딩과 sanitizer를 대신하지 않습니다.

Nest 공식 보안 헤더 문서에 따르면 NestJS 12.1부터 app.useSecurityHeaders()가 Helmet 8과 같은 기본값으로 보안 헤더를 설정하며, Express와 Fastify에서 똑같이 동작하고 별도 패키지가 필요 없습니다. 기본으로 Content-Security-Policy, Strict-Transport-Security, X-Frame-Options, X-Content-Type-Options 같은 헤더를 보내고 X-Powered-By를 지웁니다.

src/main.ts (부분 발췌)
const app = await NestFactory.create(AppModule);

app.useSecurityHeaders({
  contentSecurityPolicy: {
    directives: {
      // 같은 이름의 기본 지시어를 대체하므로 'self'를 함께 적습니다.
      scriptSrc: ["'self'"],
    },
  },
});
app.enableCsrfProtection({
  trustedOrigins: ['https://app.example.com', 'https://admin.example.com'],
});

useSecurityHeaders()도 app.init()이나 app.listen()보다 먼저, NestFactory.create() 바로 뒤에서 호출합니다. 그보다 먼저 app.use()로 등록한 middleware가 직접 보낸 응답에는 헤더가 붙지 않기 때문입니다. CSP 지시어는 같은 이름의 기본값을 대체하므로 'self'를 유지하려면 함께 적어야 하고, 옵션 이름이나 값이 잘못되면 시작할 때 오류가 납니다. 두 메서드는 같은 요청 hook을 공유하므로 호출 순서와 상관없이 헤더가 CSRF 검사보다 먼저 쓰여, 거부된 403 응답에도 보안 헤더가 붙습니다. NestJS 12.1보다 이전 버전이라면 Express에서는 helmet 패키지를, Fastify에서는 @fastify/helmet 플러그인을 route보다 먼저 등록해 같은 효과를 얻을 수 있습니다.

보안 헤더는 응답 헤더를 설정할 뿐 TLS 연결을 만들지 않습니다. HTTPS는 애플리케이션이나 앞단 프록시에서 따로 처리해야 하며, 브라우저는 일반 HTTP로 받은 Strict-Transport-Security를 무시하므로 HSTS는 HTTPS 응답으로 보내야 효과가 있습니다.


요청부터 렌더링까지 확인하기

CORS의 출처 제한, CSRF의 요청 의도 확인, DTO의 입력 검증, XSS의 출력 방어, 보안 응답 헤더는 서로 이어지지만 역할이 겹치지 않습니다.

Nest 요청·응답 보안 경계 체크리스트

preflight 여부·실제 request 전송·서버 판정·CORS 노출·렌더링으로 진행하며 서버 조건을 고정 Nest 순서로 나누지 않는다.

Nest 애플리케이션 요청부터 브라우저 렌더링까지의 보안 경계 체크리스트

Nest · Request-to-render checklist

요청부터 렌더링까지 보안 점검

CORS·인증과 인가·CSRF·입력 검증·출력 처리·응답 헤더를 역할별로 점검합니다. 번호는 점검 순서이며 실제 Nest 실행 순서는 어댑터와 미들웨어 등록 위치에 따라 확인합니다.

브라우저 전송·서버 검사·렌더링의 서로 다른 경계 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 미허용이면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다. 설정 확인 미허용 허용 / simple 실패 통과 시작 시 정책 등록 CORS · Helmet · HTTPS 필요한 preflight 예정 method · header 미허용 이 실제 요청은 미전송 실제 요청 simple / 허용된 협상 서버 보호 경계 인증·인가 · CSRF DTO · 업무 조건 검사 실패 쓰기 전 서버 거부 성공 응답 JSON · 안전한 오류 브라우저 수신 응답 노출 · 헤더 확인 문맥별 렌더링 텍스트 / HTML / URL
전송 조건·서버 보호·출력 경계의 분리 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 미허용이면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다. 미허용 실패 시작 시 정책 등록 CORS · Helmet · HTTPS 필요한 preflight 예정 method · header 전송 허용 여부 미허용 이 실제 요청은 미전송 실제 요청 simple / 허용된 협상 서버 보호 경계 인증·인가 · CSRF DTO · 업무 조건 검사 실패 쓰기 전에 서버 거부 성공 응답 JSON · 안전한 오류 브라우저 수신 응답 노출 · 헤더 확인 문맥별 렌더링 텍스트 / HTML / URL

요청 → 응답 → 렌더링

  1. 부트스트랩 · 출처와 헤더 정책 등록

    exact allowlist 또는 엄격한 predicate로 Origin을 판정하고 메서드, 요청용 allowedHeaders, credentials, 응답용 exposedHeaders를 분리합니다. Express·Fastify에서 CORS와 Helmet의 패키지·등록 순서를 각각 확인합니다.

  2. 선택적 preflight · 실제 요청 가능 여부 협상

    브라우저는 non-simple 교차 출처 요청 전에 Origin, 예정 메서드와 요청 헤더를 보냅니다. 서버 응답의 allowed method·header는 전송 가능 여부를 알릴 뿐 사용자 인증이나 권한을 증명하지 않습니다.

  3. 실제 요청 · 인증과 인가는 서버에서 수행

    쿠키 포함 여부는 요청의 credentials mode와 쿠키 속성이 정합니다. CORS 응답 헤더는 credentialed 응답을 JavaScript에 공유할 조건이며 요청 자체의 전송 조건이 아니므로, guard와 권한 검사로 요청 주체와 작업 권한을 확인합니다.

  4. 상태 변경 · CSRF 의도 검증

    POST·PUT·PATCH·DELETE에서 세션 결합 토큰을 검증하고 Origin·Fetch Metadata·SameSite를 보조 신호로 사용합니다. safe method는 상태를 바꾸지 않습니다.

  5. DTO 경계 · 입력 형태와 허용 필드 검증

    ValidationPipe와 DTO로 타입, 형태, 필수값, 허용 필드를 확인한 뒤 비즈니스 규칙을 적용합니다. 이 입력 검증은 이후 렌더링 컨텍스트의 출력 인코딩을 대신하지 않습니다.

  6. 응답 생성 · 데이터와 실행 컨텍스트 분리

    JSON은 올바른 content type으로 반환하고, HTML 텍스트·속성·URL·JavaScript 출력은 각 컨텍스트에 맞게 처리합니다. 일반 React JSX의 이스케이프를 유지하고 raw HTML은 sanitizer 정책을 통과시킵니다.

  7. 브라우저 수신 · 노출 헤더와 추가 방어 확인

    exposedHeaders는 JavaScript가 읽을 응답 헤더만 정합니다. Helmet·CSP·HttpOnly는 추가 방어선이며, HTTPS는 별도로 제공하고 HSTS는 안전한 응답에서 이후 연결을 위해 전달합니다.

성공 조건은 허용되지 않은 Origin, 잘못된 CSRF 토큰·DTO, 실행 가능한 출력 payload가 각각 자신의 경계에서 거부되거나 데이터로 처리되는 것입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 허용되지 않으면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest 요청·응답 보안 경계 체크리스트
7부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 허용되지 않으면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
브라우저 전송 경계

필요한 preflight가 허용되지 않으면 실제 요청을 보내지 않습니다.

미허용: 전송 없음 · simple·허용: 전송브라우저 정책이 서버 도달 여부를 정합니다.
서버 조건 확인

인증·인가·필요한 CSRF·DTO·업무 조건을 검사합니다.

한 실패: 쓰기 전 거부 · 검사 총순서: 고정 안 함허용된 실제 요청만 쓰기에 도달합니다.
응답 노출·렌더

CORS 노출 조건·헤더 뒤 출력 문맥에 맞게 렌더합니다.

응답: 노출 조건 · 출력: 문맥별 처리 · HTTPS: 배포 조건서버 성공과 브라우저 사용 가능 결과를 구분합니다.

Nest 요청·응답 보안 경계 체크리스트

preflight 여부·실제 request 전송·서버 판정·CORS 노출·렌더링으로 진행하며 서버 조건을 고정 Nest 순서로 나누지 않는다.

Nest 애플리케이션 요청부터 브라우저 렌더링까지의 보안 경계 체크리스트

Nest · Request-to-render checklist

요청부터 렌더링까지 보안 점검

CORS·인증과 인가·CSRF·입력 검증·출력 처리·응답 헤더를 역할별로 점검합니다. 번호는 점검 순서이며 실제 Nest 실행 순서는 어댑터와 미들웨어 등록 위치에 따라 확인합니다.

브라우저 전송·서버 검사·렌더링의 서로 다른 경계 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 미허용이면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다. 설정 확인 미허용 허용 / simple 실패 통과 시작 시 정책 등록 CORS · Helmet · HTTPS 필요한 preflight 예정 method · header 미허용 이 실제 요청은 미전송 실제 요청 simple / 허용된 협상 서버 보호 경계 인증·인가 · CSRF DTO · 업무 조건 검사 실패 쓰기 전 서버 거부 성공 응답 JSON · 안전한 오류 브라우저 수신 응답 노출 · 헤더 확인 문맥별 렌더링 텍스트 / HTML / URL
전송 조건·서버 보호·출력 경계의 분리 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 미허용이면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다. 미허용 실패 시작 시 정책 등록 CORS · Helmet · HTTPS 필요한 preflight 예정 method · header 전송 허용 여부 미허용 이 실제 요청은 미전송 실제 요청 simple / 허용된 협상 서버 보호 경계 인증·인가 · CSRF DTO · 업무 조건 검사 실패 쓰기 전에 서버 거부 성공 응답 JSON · 안전한 오류 브라우저 수신 응답 노출 · 헤더 확인 문맥별 렌더링 텍스트 / HTML / URL

요청 → 응답 → 렌더링

  1. 부트스트랩 · 출처와 헤더 정책 등록

    exact allowlist 또는 엄격한 predicate로 Origin을 판정하고 메서드, 요청용 allowedHeaders, credentials, 응답용 exposedHeaders를 분리합니다. Express·Fastify에서 CORS와 Helmet의 패키지·등록 순서를 각각 확인합니다.

  2. 선택적 preflight · 실제 요청 가능 여부 협상

    브라우저는 non-simple 교차 출처 요청 전에 Origin, 예정 메서드와 요청 헤더를 보냅니다. 서버 응답의 allowed method·header는 전송 가능 여부를 알릴 뿐 사용자 인증이나 권한을 증명하지 않습니다.

  3. 실제 요청 · 인증과 인가는 서버에서 수행

    쿠키 포함 여부는 요청의 credentials mode와 쿠키 속성이 정합니다. CORS 응답 헤더는 credentialed 응답을 JavaScript에 공유할 조건이며 요청 자체의 전송 조건이 아니므로, guard와 권한 검사로 요청 주체와 작업 권한을 확인합니다.

  4. 상태 변경 · CSRF 의도 검증

    POST·PUT·PATCH·DELETE에서 세션 결합 토큰을 검증하고 Origin·Fetch Metadata·SameSite를 보조 신호로 사용합니다. safe method는 상태를 바꾸지 않습니다.

  5. DTO 경계 · 입력 형태와 허용 필드 검증

    ValidationPipe와 DTO로 타입, 형태, 필수값, 허용 필드를 확인한 뒤 비즈니스 규칙을 적용합니다. 이 입력 검증은 이후 렌더링 컨텍스트의 출력 인코딩을 대신하지 않습니다.

  6. 응답 생성 · 데이터와 실행 컨텍스트 분리

    JSON은 올바른 content type으로 반환하고, HTML 텍스트·속성·URL·JavaScript 출력은 각 컨텍스트에 맞게 처리합니다. 일반 React JSX의 이스케이프를 유지하고 raw HTML은 sanitizer 정책을 통과시킵니다.

  7. 브라우저 수신 · 노출 헤더와 추가 방어 확인

    exposedHeaders는 JavaScript가 읽을 응답 헤더만 정합니다. Helmet·CSP·HttpOnly는 추가 방어선이며, HTTPS는 별도로 제공하고 HSTS는 안전한 응답에서 이후 연결을 위해 전달합니다.

성공 조건은 허용되지 않은 Origin, 잘못된 CSRF 토큰·DTO, 실행 가능한 출력 payload가 각각 자신의 경계에서 거부되거나 데이터로 처리되는 것입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 허용되지 않으면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest 요청·응답 보안 경계 체크리스트
7부트스트랩 설정과 실제 요청을 나눈다. 필요한 preflight가 허용되지 않으면 이 브라우저의 실제 요청은 전송되지 않는다. simple request 또는 허용된 preflight 뒤의 실제 요청은 서버의 인증·인가, 필요한 CSRF, DTO·업무 조건을 각각 통과해야 한다. 이 서버 조건들의 도식은 고정된 Nest 실행 순서가 아니다. 하나라도 실패하면 쓰기 전에 거부한다. 성공 응답도 CORS 응답 노출 조건과 응답 헤더 확인을 거치며, 렌더러는 출력 문맥에 맞춰 데이터를 다룬다. HTTPS는 별도 배포 조건이다. 원래 번호 목록은 검토 체크리스트이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
브라우저 전송 경계

필요한 preflight가 허용되지 않으면 실제 요청을 보내지 않습니다.

미허용: 전송 없음 · simple·허용: 전송브라우저 정책이 서버 도달 여부를 정합니다.
서버 조건 확인

인증·인가·필요한 CSRF·DTO·업무 조건을 검사합니다.

한 실패: 쓰기 전 거부 · 검사 총순서: 고정 안 함허용된 실제 요청만 쓰기에 도달합니다.
응답 노출·렌더

CORS 노출 조건·헤더 뒤 출력 문맥에 맞게 렌더합니다.

응답: 노출 조건 · 출력: 문맥별 처리 · HTTPS: 배포 조건서버 성공과 브라우저 사용 가능 결과를 구분합니다.

위 그림은 요청이 들어와 응답이 브라우저에 렌더링될 때까지 점검할 항목을 순서대로 보여 줍니다. 부트스트랩 단계의 설정과 실제 요청 처리를 나누고, 브라우저의 전송 규칙, 서버의 CORS·인증·인가·CSRF·입력 검증, 렌더링 단계의 출력 처리와 응답 헤더를 각각 따로 확인합니다. 그림의 번호는 점검 순서이며, 실제 Nest의 실행 순서는 어댑터와 middleware 등록 위치에 따라 직접 확인해야 한다는 점이 그림 위에 적혀 있습니다.

보안은 설정이 있는지가 아니라 실패하는 경로로 확인합니다. 허용하지 않은 출처의 응답 읽기, 의도하지 않은 상태 변경, 잘못된 DTO, 악성 출력 값, HTTPS가 아닌 HSTS 전달이 각각 기대한 지점에서 실패하는지 자동화된 테스트로 확인합니다. OWASP CSRF 지침과 XSS 지침을 함께 보면 각 테스트가 지키는 지점을 구분하는 데 도움이 됩니다.


정리

  • CORS는 다른 출처의 응답을 JavaScript가 읽어도 되는지를 정할 뿐, 인증·인가나 CSRF 방어가 아닙니다.
  • NestJS 12.1의 app.enableCsrfProtection()은 Sec-Fetch-Site와 Origin으로 다른 출처의 상태 변경 요청을 거부하며, CORS로 허용한 출처라도 쿠키로 쓰기 요청을 보낸다면 trustedOrigins에 따로 적어야 합니다.
  • GET, HEAD, OPTIONS는 CSRF 검사를 받지 않으므로 상태를 바꾸면 안 되고, 아주 오래된 브라우저까지 보호하려면 토큰 방식을 더합니다.
  • XSS는 출력 위치마다 맞는 인코딩과 sanitizer로 막으며, DTO 검증이나 CSP가 이를 대신하지 않습니다.
  • NestJS 12.1의 app.useSecurityHeaders()는 Helmet 8과 같은 기본 보안 헤더를 설정하고, NestFactory.create() 바로 뒤에서 호출합니다.

연습 문제

1. 헤더 방향과 신뢰 출처

허용 출처인 https://app.example.com에서 토큰 방식의 JSON POST를 보냅니다. preflight가 필요한 요청이며, 서버는 exposedHeaders에 X-CSRF-Token을 추가했지만 allowedHeaders에서는 그 이름을 지웠습니다. 이 상태로 토큰 헤더를 실제 요청에 실을 수 있을까요? 또 allowedHeaders를 고친 뒤, 내장 CSRF 보호의 trustedOrigins에 이 출처를 넣지 않았다면 요청은 어떻게 될까요?

해설 보기

exposedHeaders는 응답 헤더를 JavaScript가 읽는 방향의 설정입니다. 보내려는 요청 헤더가 preflight 응답에서 허용되지 않으면, 브라우저는 이 실제 요청을 아예 보내지 않습니다.

allowedHeaders를 고쳐 요청이 전송되더라도, app.example.com에서 api.example.com으로 가는 요청은 브라우저가 Sec-Fetch-Site: same-site로 표시합니다. 내장 보호는 same-origin과 none만 허용하므로, trustedOrigins에 없는 이 출처의 POST는 403으로 거부됩니다. CORS 허용과 CSRF 신뢰는 따로 설정해야 합니다.

2. GET으로 만든 로그아웃

어떤 API가 GET /auth/logout을 호출하면 세션을 지우도록 만들어져 있고, 내장 CSRF 보호가 켜져 있습니다. 공격자가 자기 사이트에 <img src="https://api.example.com/auth/logout">을 넣어 두었다면, 로그인한 사용자가 그 사이트를 열 때 무슨 일이 생길까요?

해설 보기

내장 CSRF 보호는 GET, HEAD, OPTIONS 요청을 항상 허용합니다. 브라우저는 이미지 요청에도 쿠키 조건이 맞으면 세션 쿠키를 붙이므로, 사용자가 공격자의 사이트를 여는 것만으로 로그아웃 요청이 전송되고 세션이 지워집니다.

이 문제는 CSRF 보호의 설정이 아니라 API 설계의 문제입니다. 세션을 지우는 것처럼 상태를 바꾸는 작업은 POST /auth/logout처럼 검사 대상인 메서드로 바꿔야 합니다.

3. HTTP로 연 스테이징의 Swagger UI

app.useSecurityHeaders()를 기본값으로 켠 앱을 스테이징 서버에 배포하고, IP 주소와 일반 HTTP로 Swagger UI에 접속했더니 화면이 깨지고 스크립트를 불러오지 못했습니다. 로컬의 localhost에서는 잘 보였습니다. 원인과 해결 방법은 무엇일까요?

해설 보기

기본 CSP에는 upgrade-insecure-requests 지시어가 들어 있어, 브라우저가 스크립트와 스타일시트를 HTTPS로 바꿔 불러옵니다. 서버가 HTTP로만 응답하므로 HTTPS로 바뀐 요청이 실패하고, Swagger UI가 필요한 파일을 불러오지 못합니다. 공식 문서도 로컬 개발 밖에서 일반 HTTP로 Swagger UI를 제공하면 이렇게 실패한다고 설명합니다.

가장 좋은 해결은 스테이징도 HTTPS로 제공하는 것입니다. 당장 그럴 수 없다면 contentSecurityPolicy.directives에 upgradeInsecureRequests: null을 주어 이 지시어만 빼되, 운영 환경에서는 HTTPS와 함께 기본값을 유지합니다.

다음 글 환경 변수 관리와 비밀 정보 보호에서는 데이터베이스 비밀번호나 API 키 같은 비밀 값을 코드와 분리해 안전하게 다루는 방법을 다룹니다.