Spring

Spring Cloud Gateway CORS 주의사항

ybs 2021. 1. 24. 23:49
반응형

CORS 요청을 받기 위해 서버가 해줘야 하는 작업들이 있다.

그런데 Spring Cloud Gateway 는 프록시 서버이기 때문에 조금 더 신경써야 하는 부분이 있어 정리했다.

※ CORS 기본 개념까지 같이 설명하기엔 글이 너무 길어져서 안다는 전제로 작성했다.

 

먼저 Spring Cloud Gateway 는 CORS 요청을 디폴트로 막는다.

CORS 허용하는 방법

1) CorsWebFilter

첫번째 방법은 CorsWebFilter 를 이용하는 것인데, 헷갈리지 말아야 될 게 있다.
CorsWebFilter 는 Spring Cloud Gateway 에서 제공하는 필터가 아니다. org.springframework.web.cors.reactive 에서 제공하는 필터다. 그래서 Spring Cloud Gateway 에 등록된 필터보다 먼저 동작한다.

 

빈 등록을 위해선 아래 코드처럼 CorsConfigurationSource 를 생성해 CorsWebFilter 에 넣어준다.

 

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.addAllowedHeader("*");
    corsConfiguration.addAllowedOrigin("*");
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.setAllowCredentials(true);
    source.registerCorsConfiguration("/**", corsConfiguration);
    return source;
}

@Bean
public CorsWebFilter corsWebFilter() {
    return new CorsWebFilter(corsConfigurationSource());
}

 

그리고 CorsWebFilter 의 filter 로직을 보면 어떻게 동작하는지 알 수 있다.

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
    ServerHttpRequest request = exchange.getRequest();
    CorsConfiguration corsConfiguration = this.configSource.getCorsConfiguration(exchange);
    boolean isValid = this.processor.process(corsConfiguration, exchange);
    if (!isValid || CorsUtils.isPreFlightRequest(request)) {
        return Mono.empty();
    }
    return chain.filter(exchange);
}

 

CORS 요청인지 확인하고 만약 valid 하지 않거나 preflight 요청이면 다음 필터로 보내지 않고 Mono.empty() 로 리턴해버린다.
this.processor.process(corsConfiguration, exchange) 메서드에서는 valid 체크 뿐 만 아니라 preflight 일 때 응답으로 보내줄 CORS 관련 헤더들을 셋팅하는 작업도 수행한다.

 

여기서 preflight 일 때 리턴해버리는게 주의할 점인데 자세한 내용은 뒤에서 설명하겠다.

2) GlobalCorsProperties

두번째 방법은 GlobalCorsProperties 를 이용하는 방법이다.
기본적으로 GatewayAutoConfiguration 에 GlobalCorsProperties 가 빈으로 등록되는데

 

@ConfigurationProperties("spring.cloud.gateway.globalcors")
public class GlobalCorsProperties {

    private final Map<String, CorsConfiguration> corsConfigurations = new LinkedHashMap<>();

    public Map<String, CorsConfiguration> getCorsConfigurations() {
        return corsConfigurations;
    }

}

 

spring.cloud.gateway.globalcors 설정이 프로퍼티 파일에 있으면 corsConfigurations 변수에 담기게 된다.

참고 : https://cloud.spring.io/spring-cloud-gateway/multi/multi__cors_configuration.html

 

10. CORS Configuration

The gateway can be configured to control CORS behavior. The "global" CORS configuration is a map of URL patterns to Spring Framework CorsConfiguration. application.yml.  spring: cloud: gateway: globalcors: corsConfigurations: '[/**]': allowedOrigins: "htt

cloud.spring.io

그리고 Spring Cloud Gateway 에서 주로 쓰이는 RoutePredicateHandlerMapping(AbstractHandlerMapping 상속받음) 에 GlobalCorsProperties 가 들어간다.

 

@Bean
public RoutePredicateHandlerMapping routePredicateHandlerMapping(
        FilteringWebHandler webHandler, RouteLocator routeLocator,
        GlobalCorsProperties globalCorsProperties, Environment environment) {
    return new RoutePredicateHandlerMapping(webHandler, routeLocator,
            globalCorsProperties, environment);
}

 

그리고 라우팅된 요청이 왔을 때 getHandler 코드를 보면(실제 구현체는 AbstractHandlerMapping에 있음) CORS 로직이 있다.

@Override
public Mono<Object> getHandler(ServerWebExchange exchange) {
return getHandlerInternal(exchange).map(handler -> {
    if (logger.isDebugEnabled()) {
        logger.debug(exchange.getLogPrefix() + "Mapped to " + handler);
    }

    ServerHttpRequest request = exchange.getRequest();

    if (hasCorsConfigurationSource(handler) || CorsUtils.isPreFlightRequest(request)) {
        CorsConfiguration config = (this.corsConfigurationSource != null ? this.corsConfigurationSource.getCorsConfiguration(exchange) : null);
        CorsConfiguration handlerConfig = getCorsConfiguration(handler, exchange);
        config = (config != null ? config.combine(handlerConfig) : handlerConfig);
        if (!this.corsProcessor.process(config, exchange) || CorsUtils.isPreFlightRequest(request)) {
            return REQUEST_HANDLED_HANDLER; // REQUEST_HANDLED_HANDLER 는 exchange -> Mono.empty() 다.
        }
    }
    return handler;
});
}

아까 CorsWebFilter 로직과 비슷하다. 여기서도 preflight 요청이면 리턴한다.

문제점

아까 코드에서 봤듯이 Spring Cloud Gateway 는 preflight 요청 일 때 뒷단 서버로 보내지 않고 리턴 시켜버린다. 다시말해 실제 뒷단 서버에서 preflight 요청을 받아 응답을 준게 아니다.

문제는 Spring Cloud Gateway 에서 준 preflight 응답에서 Access-Control-Allow-Origin 값이 있어야 하는데 정할 수가 없다. 뒷단 서버들의 도메인들이 다 다르기 때문이다. 설령 뒷단 서버 도메인들을 다 추가한다 하더라도 동적으로 새롭게 늘어갈 때마다 추가해줘야 한다. 그리고 allow origin 검사가 exact matching 이기 때문에 *.ybs.com 같이 패턴을 넣는것도 불가능하다.

그래서 Access-Control-Allow-Origin*로 해줄 수 밖에 없지만 이마저도 만족스럽지 않다.

본 요청에서 Access-Control-Allow-Origin 헤더를 또 보내서 두개가 되버려 Spring Cloud Gateway 에서 헤더 중복 제거를 해줘야 한다.

 

더 큰 문제는 Access-Control-Allow-Credentials 이다. Spring Cloud Gateway 에서 True/False 를 결정할 수 없다. 뒷단 서버에서 정해줘야 한다.

 

결국 이 문제를 해결하려면 CORS 요청을 Spring Cloud Gateway 에서 검사하지 않고 그냥 통과 시켜야 한다.

해결 방법

먼저 CorsWebFilter, GlobalCorsProperties 둘 다 사용하면 안된다.

그리고 RoutePredicateHandlerMapping 클래스의 getHandler 메서드에서(실제 구현체는 AbstractHandlerMapping에 있음) 강제로 CORS 체크를 하기 때문에 getHandler 메서드를 오버라이딩 해야 된다.

 

아래 코드는 RoutePredicateHandlerMapping 을 상속받은 PassCorsRoutePredicateHandlerMapping 을 새롭게 만들어 오버라이딩했다.

public class PassCorsRoutePredicateHandlerMapping extends RoutePredicateHandlerMapping {

    private static final Logger logger = LoggerFactory.getLogger(PassCorsRoutePredicateHandlerMapping.class);

    public PassCorsRoutePredicateHandlerMapping(FilteringWebHandler webHandler, RouteLocator routeLocator,
            GlobalCorsProperties globalCorsProperties, Environment environment) {
        super(webHandler, routeLocator, globalCorsProperties, environment);
    }

    @Override
    public Mono<Object> getHandler(ServerWebExchange exchange) {
        logger.info("[PassCorsRoutePredicateHandlerMapping] getHandler");
        return getHandlerInternal(exchange).map(handler -> {
            logger.info(exchange.getLogPrefix() + "Mapped to " + handler);

            // CORS 체크 로직 제거

            return handler;
        });
    }
}

 

변경사항은 CORS 로직만 제거 했다. 그리고 빈등록만 해주면 된다.

@Bean
@Primary
public RoutePredicateHandlerMapping passCorsRoutePredicateHandlerMapping(
        FilteringWebHandler webHandler, RouteLocator routeLocator,
        GlobalCorsProperties globalCorsProperties, Environment environment) {
    return new PassCorsRoutePredicateHandlerMapping(webHandler, routeLocator,
            globalCorsProperties, environment);
}

 

cf) 이 글을 작성하는 2021년 1월 기준으로, CORS preflight 요청을 그냥 통과시키는 옵션은 제공하지 않는다.
https://github.com/spring-cloud/spring-cloud-gateway/pull/1883에서 PR은 확인했지만 아직 머지되지 않았다.

 

반응형