이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 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.exampleAccess-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: authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: authorization

Origin 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: true

credential 포함 요청에서는 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=4a0d11c2
level=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=4a0d11c3

CORS 디버깅 로그는 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 호출에는 적용되지 않기 때문이다.

참고 문서