이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- CORS는 인증/인가가 아니라 응답 읽기 정책이라는 말을 어떻게 적용하는가?
- Preflight 요청이 실패할 때 서버와 Spring Security에서 무엇을 확인해야 하는가?
- credential 포함 CORS에서 Origin allowlist와
Vary: Origin이 왜 중요한가?
개요
CORS는 Cross-Origin Resource Sharing이다.
브라우저가 cross-origin 요청의 응답을 JavaScript에 노출해도 되는지 서버가 알려 주는 정책이다.
CORS는 서버 인증이나 권한 검사의 대체재가 아니다. 서버 간 HTTP client에는 브라우저 CORS 제한이 적용되지 않는다.
Preflight는 실제 요청 전에 브라우저가 OPTIONS 요청으로 method와 header 허용 여부를 확인하는 절차다.
공격 시나리오
개발 중 CORS 에러를 빠르게 없애기 위해 서버가 요청 Origin을 그대로 반사한다.
공격자는 https://evil.example에서 다음 JavaScript를 실행한다.
fetch("https://api.example.com/api/me", {
credentials: "include"
}).then(r => r.json()).then(sendToAttacker);서버가 Access-Control-Allow-Origin: https://evil.example와 Access-Control-Allow-Credentials: true를 반환하면 공격 페이지가 피해자의 인증된 API 응답을 읽을 수 있다.
CORS는 요청을 보내는 문제가 아니라 응답을 읽는 문제라는 점이 여기서 드러난다.
취약한 요청/응답 예시
OPTIONS /api/me HTTP/1.1
Host: api.example.com
Origin: https://evil.example
Access-Control-Request-Method: GET
Access-Control-Request-Headers: authorizationHTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: authorizationOrigin allowlist 없이 반사하면 credential 포함 API 응답 읽기를 공격자 origin에 열어 준다.
주제별 핵심 판단
예를 들어 https://app.example.com에서 https://api.example.com으로 JSON POST를 보낸다.
OPTIONS /api/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, x-csrf-token서버가 허용하면 다음처럼 응답한다.
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type, x-csrf-token
Access-Control-Allow-Credentials: true
Vary: Origin그 뒤 브라우저가 실제 POST를 보낸다.
개선된 요청/응답 예시
CORS 정책은 endpoint 성격별로 좁게 둔다.
- 개선된 CORS 응답은 허용 origin을 반사하지 않고 allowlist와
Vary: Origin을 함께 사용한다. - preflight와 실제 요청의 method/header/credential 정책이 서로 맞는지 확인한다.
방어 설계
public read API:
allowed origins: none or public documentation domain
credentials: false
user API:
allowed origins: https://app.example.com
credentials: true
admin API:
allowed origins: https://admin.example.com
credentials: truecredential 포함 요청에서는 Access-Control-Allow-Origin: *를 사용할 수 없다. 명시적 Origin을 반환하고 Vary: Origin을 붙여 캐시 혼선을 줄인다.
Preflight는 인증 쿠키 없이 들어올 수 있으므로 Spring Security보다 CORS 처리가 먼저 적용되도록 구성한다.
Spring/HTTP 예시
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com", "https://admin.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN", "Authorization"));
config.setAllowCredentials(true);
config.setMaxAge(Duration.ofMinutes(10));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.cors(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated())
.build();
}운영에서는 allowedOriginPatterns("*")와 credentials 조합을 특히 조심한다.
운영 로그와 감사 지점
level=WARN event=cors_rejected result=blocked origin=https://evil.example method=GET request_headers=authorization path=/api/me reason=origin_not_allowed trace_id=4a0d11c2level=INFO event=cors_preflight_allowed result=allowed origin=https://app.example.com method=POST request_headers=content-type,x-csrf-token path=/api/orders trace_id=4a0d11c3CORS 디버깅 로그는 Origin, requested method, requested headers, path, allow/deny reason을 남긴다. Authorization header 값은 남기지 않는다.
실전 판단 기준
- 허용되지 않은 Origin이 preflight에서 거부되는가?
- preflight 성공은 실제 요청의 인증/인가 성공을 뜻하지 않는다.
- credential 포함 CORS는 민감 응답 읽기 가능성을 기준으로 위험도를 판단한다.
테스트 포인트
- credential 포함 API가
Access-Control-Allow-Origin: *를 반환하지 않는가? - 응답에
Vary: Origin이 있는가? - Preflight가 인증 필터에서 401로 막히지 않는가?
- admin origin과 app origin의 허용 endpoint가 분리되어 있는가?
- curl 또는 서버 간 요청이 CORS 없이도 권한 검사를 통과해야 하는지 별도 테스트하는가?
위험 신호!
- 요청 Origin을 그대로
Access-Control-Allow-Origin에 반사한다. allowCredentials(true)와 넓은 origin pattern을 같이 쓴다.- CORS를 열면 인증이 열렸다고 오해하거나, CORS를 막으면 인증이 막힌다고 오해한다.
- Preflight 401을 해결하려고 모든 endpoint를 permitAll로 연다.
Vary: Origin없이 동적 Origin 응답을 캐시한다.- 개발 Origin이 운영 allowlist에 남아 있다.
확인 질문
확인 질문
- CORS가 해결하는 문제는 무엇인가?
- 브라우저가 cross-origin 응답을 JavaScript에 노출해도 되는지 결정하는 문제다.
- Preflight가 인증 필터에서 막히면 왜 문제가 되는가?
- 실제 요청 전에 브라우저가 허용 여부를 확인해야 하는데, 인증 쿠키 없이 들어온 OPTIONS가 먼저 거부될 수 있기 때문이다.
- CORS가 authorization을 대체하지 못하는 이유는 무엇인가?
- CORS는 브라우저 정책이며 서버 간 호출과 직접 API 호출에는 적용되지 않기 때문이다.