본문 바로가기

Spring

[Spring] CORS 이란? CORS 에러 해결방법

프로젝트를 할때마다 CORS 설정을 해주고 있지만 매번 CORS 에러를 만나는게 정확한 이해없이 사용하고 있는 것 같아 한번 정리하고 넘어가려고 합니다.

CORS 란?

CORSCross Origin Resource Sharing의 약자로 출처가 다른 리소스들을 공유하는 것을 의미합니다. 여기서 출처란 프로토콜, 호스트, 포트로 구성된 서버 위치를 의미하는데 두 리소스들 간에 프로토콜, 호스트, 포트 중 하나만 달라도 다른 리소스에서 요청을 보냈을 때 CORS 에러가 발생합니다.

 

ex) https://spring.io:8080 

  • 프로토콜 : https, http
  • 호스트 : spring.io
  • 포트 : 8080

그렇다면 왜 이처럼 출처가 다른 리소스의 요청을 막는 것일까?

출처가 다른 리소스를 공유하여 브라우저를 실행하는 행위는 보안상 매우 위험합니다. 사용자의 개인 정보가 유출될 수 있으며, XSS(Cross Site Scripting) 같은 방식으로 조작된 데이터가 클라이언트에 전달될 수 있기 때문인데요. 그래서 브라우저들은 같은 출처의 리소스만 사용하도록 제한하는 방식인 SOP(Same Origin Policy) 정책을 따르도록 되어 있습니다.

 

하지만 SOP는 다음과 같은 예외 사항이 있으며 CORS 인증 과정을 거치면 다른 출처의 리소스도 사용할 수 있습니다.

  • <img> 태그를 사용하여 다른 출처의 이미지 파일을 요청하는 경우
  • <link> 태그를 사용하여 다른 출처의 CSS 파일을 요청하는 경우
  • <script> 태그를 사용하여 다른 출처의 자바 스크립트 파일을 요청하는 경우

CORS 인증과정

CORS 인증과정을 통과하려면 프리플라이트(preflight)라는 과정을 거쳐야 합니다. preflight는 다른 출처의 리소스를 요청하기 전에 사용 가능 여부를 물어보는 과정으로 클라이언트는 preflight의 응답 메시지에 포함된 헤더와 상태 코드를 읽고 CORS 사용 여부를 판단합니다.

 

 

CORS 인증은 위와 같은 과정을 거칩니다. 먼저 웹 브라우저가 http://www.springtour.io 호스트에서 Html 리소스를 받아갑니다. Html 리소스를 받아올 때 api.js JavaScript 문서도 함께 받아오고 js 문서에 포함된 hotels라는 REST-API를 사용해 화면에 보여줄 데이터를 http://api.springtour.io 호스트로부터 받아옵니다. 

 

하지만 이때 두 호스트의 출처가 다르기 때문에 먼저 프리플라이트 요청을 보내는데 요청 메시지에는 다음과 같은 정보들이 담겨 있습니다.

  • Host : 요청 대상 (http://api.springtour.io)
  • Origin : 요청을 보내는 출처 (www.springtour.io)
  • Access-Control-Request-Method : 특정 메서드에 대한 접근 권한 요청
  • Access-Control-Request-Headers : 특정 헤더에 대한 접근 권한 요청

이렇게 요청을 보냈을 때 REST-API 애플리케이션(http://api.springtour.io)에서 CORS 설정이 되어있으면 프리플라이트 요청에 대한 응답 메세지를 보내줍니다.

  • Access-Control-Allow-Origin : CORS를 허용하는 출처를 의미, 모든 출처를 허용하는 경우 *으로 응답
  • Access-Control-Allow-Method : CORS를 허용하는 출처에서 사용할 수 있는 HTTP 메서드
  • Access-Control-Allow-Headers : CORS를 허용하는 출처에서 사용할 수 있는 HTTP 헤더
  • Access-Control-Max-Age : 최초 CORS 인증 후 유효 시간, 이 시간동안에는 또 다시 CORS 인증을 하지 않아도 된다.

CORS 설정 방법

1. addCorsMappings

WebMvcConfigurer 에서 제공하는 addCorsMappings() 메서드를 사용해서 CORS 설정을 할 수 있습니다. addCorsMappings() 메서드는 CorsRegistry를 인자로 받아 사용합니다.

  • addMapping("/**") : 모든 리소스에 대해 CORS를 적용
  • allowedOrigins("www.springtour.io") : www.springtour.io 출처에 대해서만 CORS 허용
  • allowedMethod("GET", "POST", "PUT") : GET, POST, PUT Http Method에 대해서만 CORS 허용
  • maxAge(24 * 60 * 60) : 하루동안 CORS 인증 유효
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
    	registry.addMapping("/**")
                .allowedOrigins("www.springtour.io")
                .allowedMethod("GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("*")
                .maxAge(24 * 60 * 60);
    }
}

2. CorsConfigurationSource

Spring Security를 사용하면 HttpSecurity에 체이닝 메서드로 cors() 설정을 해줄 수 있습니다. 이때 CorsConfigurationSource를 사용해서 CORS의 속성을 정의해 줄 수 있습니다.

 

아래 예시에서 Origin, Method, Header 관련 메서드들은 addCorsMapping() 에서 사용한 것과 같으니 다른 부분만 집고 넘어가겠습니다.

  • setAllowCredentials : 자격 증명과 함께 요청을 할 수 있는지 여부, 해당 서버에서 Authorization으로 사용자 인증을 요청할 것이라면 true로 설정해야 한다.
  • addAllowedOriginPattern : allowCredentials가 true일 때 allowedOrigins에 특수 값인 "*" 추가할 수 없게 됨, 대신 addAllowedOriginPattern을 사용
  • addExposedHeader : 특정 응답 헤더 허용
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()
                .cors().configurationSource(corsConfigurationSource());

        return http.build();
    }
    
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        // configuration.addAllowedOrigin("*");
        configuration.addAllowedOriginPattern("*");
        configuration.addAllowedMethod("*");
        configuration.addAllowedHeader("*");
        configuration.addExposedHeader("Authorization");
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

}

3. Spring Cloud Gateway 에서 CORS 설정

@Configuration
public class CorsConfiguration {
    private static final String ALLOWED_HEADERS = "authorization, Content-Type, Content-Length, Authorization, credential, X-XSRF-TOKEN, refreshtoken, RefreshToken";
    private static final String ALLOWED_METHODS = "GET, PUT, POST, DELETE, OPTIONS, PATCH";
    private static final String EXPOSE_HEADERS = "*, Authorization";
    private static final String MAX_AGE = "7200"; //2 hours (2 * 60 * 60)

    @Bean
    public WebFilter corsFilter() {
        return (ServerWebExchange ctx, WebFilterChain chain) -> {
            ServerHttpRequest request = ctx.getRequest();
            String origin = request.getHeaders().getOrigin();

            if (CorsUtils.isCorsRequest(request)) {
                ServerHttpResponse response = ctx.getResponse();
                HttpHeaders headers = response.getHeaders();

                if (origin.startsWith("http://localhost:3000") || origin.startsWith("http://j8c209.p.ssafy.io")) {
                    headers.add("Access-Control-Allow-Origin", origin);
                }

                headers.add("Access-Control-Allow-Methods", ALLOWED_METHODS);
                headers.add("Access-Control-Max-Age", MAX_AGE);
                headers.add("Access-Control-Allow-Headers", ALLOWED_HEADERS);
                headers.add("Access-Control-Expose-Headers", EXPOSE_HEADERS);
                headers.setAccessControlAllowCredentials(true);
                if (request.getMethod() == HttpMethod.OPTIONS) {
                    response.setStatusCode(HttpStatus.OK);
                    return Mono.empty();
                }
            }
            return chain.filter(ctx);
        };
    }
}