Spring Cloud Gateway CORS 주의사항
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
그리고 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은 확인했지만 아직 머지되지 않았다.