CORS, CSRF, XSS 대응
CORS·CSRF·XSS가 발생하는 신뢰 경계를 구분하고 출처 제한, 요청 의도 검증, 출력 컨텍스트 방어를 적용합니다.
9장에서는 NestJS 애플리케이션의 성능과 운영 상태를 다뤘습니다.
10장에서는 애플리케이션의 신뢰 경계를 지키는 보안(Security)을 살펴봅니다.
첫 주제는 이름이 자주 함께 등장하지만 서로 다른 문제를 해결하는 CORS, CSRF, XSS입니다.
Nest · trust boundaries
한 요청, 서로 다른 세 보안 경계
출처 제한, 쓰기 요청의 의도 확인, 출력 컨텍스트 방어는 이어서 적용하지만 어느 하나가 다른 둘을 대신하지 않습니다.
| 위협 · 실패 신호 | 방어 · 적용 지점 | 성공을 확인할 증거 | 대신하지 않는 것 |
|---|---|---|---|
| CORS · 허용하지 않은 출처의 JavaScript가 교차 출처 응답을 읽음 | 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이나 실행 코드로 해석됨 | 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는 쿠키·세션 플러그인과 보호 미들웨어의 조합·순서가 다릅니다. 증거: 인증된 세션이어도 토큰이 없거나 틀린 쓰기는 실패하고 safe method는 상태를 바꾸지 않습니다. CORS나 XSS 방어를 대신하지 않습니다. - XSS · 데이터가 코드로 해석되는 출력
- 방어: HTML 텍스트·속성·URL·JavaScript마다 맞는 인코딩과 safe sink를 사용하고 raw HTML만 sanitizer를 거칩니다. React의
dangerouslySetInnerHTML과 SSR 우회 경계를 검토합니다. 증거: 공격 payload는 데이터로 렌더링되고 raw HTML은 정책을 통과합니다. DTO 검증,HttpOnly, CSP만으로 출력 방어를 대신할 수 없습니다.
실패 지점을 분리하면 운영 기준도 선명해집니다. CORS는 응답 노출, CSRF는 쓰기 의도, XSS는 렌더링 컨텍스트에서 검증합니다.
세 방어를 먼저 분리하기
- CORS는 브라우저가 다른 출처의 응답을 JavaScript에 노출할 수 있는지 제어합니다.
- CSRF 방어는 쿠키 같은 ambient credential이 붙은 상태 변경 요청에 사용자의 의도가 있었는지 검증합니다.
- XSS 방어는 신뢰할 수 없는 데이터가 브라우저의 HTML·속성·URL·JavaScript 같은 실행 컨텍스트로 해석되지 않게 합니다.
CORS는 인증이나 인가가 아니며 CSRF 토큰을 대신하지 않습니다. 입력 검증도 출력 인코딩이나 HTML 살균을 대신하지 않습니다.
하나의 미들웨어로 세 문제를 한꺼번에 해결하려 하지 말고, 각 경계에서 별도의 실패 신호를 확인해야 합니다.
CORS: 브라우저의 교차 출처 응답 노출 정책
출처(origin)는 스킴, 호스트, 포트의 조합입니다.
동일 출처 정책은 다른 출처의 응답을 브라우저 JavaScript가 마음대로 읽지 못하게 합니다. CORS 응답 헤더는 이 제한을 선택적으로 완화합니다.
이때 중요한 점은 CORS가 서버로 향하는 모든 교차 사이트 요청을 막는 방화벽이 아니라는 것입니다. 브라우저는 simple request를 전송할 수 있고 응답 읽기만 차단할 수 있으므로, 서버는 모든 요청에 인증·인가를 수행해야 합니다.
Nest에서 출처를 명시적으로 허용하기
Nest는 Express 어댑터에서 cors, Fastify 어댑터에서 @fastify/cors를 사용합니다. 두 경우 모두 app.enableCors()로 설정하지만, 실제 지원 옵션과 기본값은 선택한 어댑터 버전의 문서를 함께 확인해야 합니다.
운영 환경에서는 문자열 전체가 일치하는 출처 목록이나 같은 수준으로 엄격한 predicate를 사용합니다. 요청의 Origin 값을 검증 없이 그대로 반사해서는 안 됩니다.
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가 읽을 수 있도록 추가로 공개할 응답 헤더입니다.- credentialed CORS 응답에는
Access-Control-Allow-Origin: *를 사용할 수 없습니다. 허용한 구체적 출처를 응답해야 합니다. - 요청의 출처에 따라 응답이 달라지면 캐시가 출처별 응답을 섞지 않도록
Vary: Origin도 확인합니다. - Nest 11의 Fastify CORS 기본 허용 메서드는 safelisted method로 좁아졌습니다.
PUT,PATCH,DELETE가 필요하면 위처럼 명시합니다.
CORS 허용은 “이 브라우저 JavaScript가 응답을 읽어도 된다”는 뜻일 뿐, 요청자가 누구인지 또는 해당 작업을 할 권한이 있는지를 증명하지 않습니다.
CSRF: ambient credential로 보낸 쓰기 요청의 의도 확인
브라우저는 쿠키의 Domain, Path, Secure, SameSite 조건이 맞으면 요청에 쿠키를 자동으로 실을 수 있습니다. 공격자는 이 성질을 이용해 사용자가 의도하지 않은 상태 변경 요청을 유도합니다.
교차 출처 fetch가 쿠키를 요청에 포함할지는 클라이언트의 credentials mode와 쿠키 속성이 결정합니다. 서버의 구체적인 Access-Control-Allow-Origin과 Access-Control-Allow-Credentials 응답은 그 credentialed 응답을 JavaScript에 공유하기 위한 조건이지, 요청 자체의 전송 조건이 아닙니다. 폼 같은 simple request도 CORS preflight 없이 전송될 수 있으므로 CORS 설정만으로 CSRF를 막을 수 없습니다.
토큰과 요청 메타데이터를 함께 검증하기
서버는 세션에 결합되고 공격자가 예측할 수 없는 CSRF 토큰을 발급합니다.
클라이언트는 POST, PUT, PATCH, DELETE 같은 상태 변경 요청의 폼 필드나 사용자 정의 헤더에 토큰을 담습니다.
서버는 토큰을 검증하고, 필요하면 Origin 또는 Fetch Metadata를 추가 신호로 확인한 뒤에만 작업을 수행합니다.
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에 넣는 것은 요청 전송을 허용하지 않습니다.
Nest의 CSRF 통합은 HTTP 어댑터에 따라 다릅니다.
- Express에서는 쿠키나 세션 파서를 먼저 등록한 뒤
csrf-csrf같은 보호 미들웨어를 연결합니다. - Fastify에서는 쿠키 또는 세션 저장소 플러그인 뒤에
@fastify/csrf-protection을 등록합니다.
패키지별 생성·검증 API와 필수 secret/session identifier는 버전에 따라 달라질 수 있으므로 설치한 버전의 공식 API를 기준으로 구성합니다. 어느 어댑터든 보호 미들웨어의 등록 순서와 토큰 없는 쓰기 요청이 실제로 거부되는지를 테스트해야 합니다.
SameSite 쿠키, Origin 검증, Fetch Metadata는 방어를 강화하지만 애플리케이션의 사이트 구성과 호환성을 고려해야 합니다. GET, HEAD, OPTIONS는 서버 상태를 바꾸지 않도록 설계합니다.
XSS: 출력되는 컨텍스트에서 데이터를 코드와 분리하기
XSS는 신뢰할 수 없는 값이 브라우저에서 마크업이나 실행 코드로 해석될 때 발생합니다. 저장형, 반사형, DOM 기반 XSS는 유입 경로가 다르지만 방어 원칙은 같습니다.
출력 위치마다 다른 방어 사용하기
- HTML 텍스트: 템플릿의 자동 이스케이프나
textContent처럼 데이터를 텍스트로 다루는 safe sink를 사용합니다. - HTML 속성: 안전한 속성만 사용하고 값을 따옴표로 감싼 뒤 속성 컨텍스트에 맞게 인코딩합니다.
- URL: 허용할 스킴을 제한하고 각 쿼리 값은 URL 인코딩한 뒤, HTML 속성에 넣을 때는 속성 처리도 적용합니다.
- JavaScript·CSS: 신뢰할 수 없는 값을 실행 코드에 직접 보간하지 않습니다. 피할 수 없다면 해당 컨텍스트 전용 인코더를 사용합니다.
- 사용자 작성 HTML: raw HTML sink에 넣기 전에 검증된 HTML sanitizer와 명시적 허용 정책을 적용합니다.
React는 일반 JSX 문자열 자식과 속성 값을 이스케이프합니다. 그러나 dangerouslySetInnerHTML은 raw HTML 경계이므로 신뢰할 수 없는 HTML을 그대로 넘기면 안 됩니다. 서버 렌더링이나 다른 템플릿 엔진도 자동 이스케이프를 우회하는 기능을 별도 검토해야 합니다.
Nest의 DTO와 ValidationPipe는 요청 형태, 타입, 허용 필드를 검사하는 입력 신뢰 경계입니다. 이 검사는 HTML·속성·URL·JavaScript 출력 컨텍스트를 알지 못하므로 출력 인코딩을 대체하지 않습니다.
JSON API는 올바른 Content-Type: application/json으로 응답하고, 렌더러가 값을 어느 sink에 넣는지까지 추적합니다. HttpOnly 쿠키는 JavaScript의 쿠키 읽기를 줄이지만 XSS가 인증된 요청을 실행하는 것까지 막지는 못합니다.
Helmet과 HTTPS의 경계
Helmet은 CSP를 포함한 HTTP 응답 헤더를 구성하는 도구입니다. CSP는 허용된 스크립트 출처를 제한하는 추가 방어선이지, 컨텍스트별 인코딩과 sanitizer를 대신하지 않습니다.
Express와 Fastify는 등록 방법이 다릅니다.
import helmet from 'helmet';
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
},
},
}));import fastifyHelmet from '@fastify/helmet';
await app.register(fastifyHelmet, {
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
},
},
});두 경우 모두 라우트보다 먼저 등록해야 헤더가 모든 응답에 적용됩니다.
Helmet은 응답 헤더를 설정할 뿐 TLS 연결을 만들지 않습니다. HTTPS는 애플리케이션이나 앞단 프록시에서 별도로 종료해야 하며, HSTS는 안전한 HTTPS 응답에서 보내야 브라우저가 이후 연결에 적용합니다.
요청부터 렌더링까지 확인하기
CORS의 출처 제한, CSRF의 요청 의도 검증, DTO의 입력 검증, XSS의 출력 컨텍스트 방어, Helmet의 응답 헤더는 서로 이어지지만 역할이 겹치지 않습니다.
다음 체크리스트는 기존의 경계·실패 신호와 운영 성공 기준을 한 흐름으로 합쳐, 요청이 들어와 응답이 렌더링될 때까지 누락을 확인하게 합니다.
Nest · request to render
경계별 실패 경로를 확인한다
부트스트랩 정책부터 브라우저의 최종 해석까지 한 방향으로 따라가면 CORS, CSRF, validation, XSS와 보안 헤더의 역할을 섞지 않고 점검할 수 있습니다.
요청 → 응답 → 렌더링
-
부트스트랩 · 출처와 헤더 정책 등록
exact allowlist 또는 엄격한 predicate로 Origin을 판정하고 메서드, 요청용
allowedHeaders, credentials, 응답용exposedHeaders를 분리합니다. Express·Fastify에서 CORS와 Helmet의 패키지·등록 순서를 각각 확인합니다. -
선택적 preflight · 실제 요청 가능 여부 협상
브라우저는 non-simple 교차 출처 요청 전에 Origin, 예정 메서드와 요청 헤더를 보냅니다. 서버 응답의 allowed method·header는 전송 가능 여부를 알릴 뿐 사용자 인증이나 권한을 증명하지 않습니다.
-
실제 요청 · 인증과 인가는 서버에서 수행
쿠키 포함 여부는 요청의 credentials mode와 쿠키 속성이 정합니다. CORS 응답 헤더는 credentialed 응답을 JavaScript에 공유할 조건이며 요청 자체의 전송 조건이 아니므로, guard와 권한 검사로 요청 주체와 작업 권한을 확인합니다.
-
상태 변경 · CSRF 의도 검증
POST·PUT·PATCH·DELETE에서 세션 결합 토큰을 검증하고 Origin·Fetch Metadata·SameSite를 보조 신호로 사용합니다. safe method는 상태를 바꾸지 않습니다. -
DTO 경계 · 입력 형태와 허용 필드 검증
ValidationPipe와 DTO로 타입, 형태, 필수값, 허용 필드를 확인한 뒤 비즈니스 규칙을 적용합니다. 이 입력 검증은 이후 렌더링 컨텍스트의 출력 인코딩을 대신하지 않습니다. -
응답 생성 · 데이터와 실행 컨텍스트 분리
JSON은 올바른 content type으로 반환하고, HTML 텍스트·속성·URL·JavaScript 출력은 각 컨텍스트에 맞게 처리합니다. 일반 React JSX의 이스케이프를 유지하고 raw HTML은 sanitizer 정책을 통과시킵니다.
-
브라우저 수신 · 노출 헤더와 추가 방어 확인
exposedHeaders는 JavaScript가 읽을 응답 헤더만 정합니다. Helmet·CSP·HttpOnly는 추가 방어선이며, HTTPS는 별도로 제공하고 HSTS는 안전한 응답에서 이후 연결을 위해 전달합니다.
성공 조건은 허용되지 않은 Origin, 잘못된 CSRF 토큰·DTO, 실행 가능한 출력 payload가 각각 자신의 경계에서 거부되거나 데이터로 처리되는 것입니다.
보안은 설정 존재 여부가 아니라 실패 경로로 검증합니다. 허용되지 않은 출처의 응답 읽기, 토큰 없는 상태 변경, 잘못된 DTO, 악성 출력 payload, HTTPS가 아닌 HSTS 전달이 각각 기대한 지점에서 실패하는지 자동화된 테스트로 확인합니다.