天天看點

Spring Cloud Gateway 2.X 跨域時出現重複Origin的BUG

版本

Spring Cloud :Hoxton.SR12

Spring Cloud Gateway : ​

​3.1.0​

問題描述

在 SpringCloud 項目中,前後端分離目前很常見,在調試時,會遇到兩種情況的跨域:

前端頁面通過不同域名或IP通路微服務的背景

例如前端人員會在本地起HttpServer 直連背景開發本地起的服務,此時,如果不加任何配置,前端頁面的請求會被浏覽器跨域限制攔截,是以,業務服務常常會添加如下代碼設定全局跨域:

@Bean
public CorsFilter corsFilter() {
    logger.debug("CORS限制打開");
    CorsConfiguration config = new CorsConfiguration();
    # 僅在開發環境設定為*
    config.addAllowedOrigin("*");
    config.addAllowedHeader("*");
    config.addAllowedMethod("*");
    config.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
    configSource.registerCorsConfiguration("/**", config);
    return new CorsFilter(configSource);
}      

前端頁面通過不同域名或IP通路SpringCloud Gateway

例如前端人員在本地起HttpServer直連伺服器的Gateway進行調試。此時,同樣會遇到跨域。需要在Gateway的配置檔案中增加:

spring:
  cloud:
    gateway:
      globalcors:
        cors-configurations:
        # 僅在開發環境設定為*
          '[/**]':
            allowedOrigins: "*"
            allowedHeaders: "*"
            allowedMethods: "*"      

那麼,此時直連微服務和網關的跨域問題都解決了,是不是很完美?

No~ 問題來了,****前端仍然會報錯:“不允許有多個’Access-Control-Allow-Origin’ CORS頭”。

Access to XMLHttpRequest at 'http://192.168.2.137:8088/api/two' from origin 'http://localhost:3200' has been blocked by CORS policy: 
The 'Access-Control-Allow-Origin' header contains multiple values '*, http://localhost:3200', but only one is allowed.      

仔細檢視傳回的響應頭,裡面包含了兩份Access-Control-Allow-Origin頭。我們用用戶端版的PostMan做一個模拟,在請求裡設定頭:Origin : * ,發現問題了:Access-Control-Allow-Origin 兩個頭重複了兩次,其中浏覽器對後者有唯一性限制!

解決的方案有兩種:

利用DedupeResponseHeader配置

spring:
    cloud:
        gateway:
          globalcors:
            cors-configurations:
              '[/**]':
                allowedOrigins: "*"
                allowedHeaders: "*"
                allowedMethods: "*"
          default-filters: #解決cors重複跨域問題
          - DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST      

DedupeResponseHeader加上以後會啟用DedupeResponseHeaderGatewayFilterFactory 在其中,dedupe方法可以按照給定政策處理值。

private void dedupe(HttpHeaders headers, String name, Strategy strategy) {
  List<String> values = headers.get(name);
  if (values == null || values.size() <= 1) {
   return;
  }
  switch (strategy) {
  // 隻保留第一個
  case RETAIN_FIRST:
   headers.set(name, values.get(0));
   break;
  // 保留最後一個        
  case RETAIN_LAST:
   headers.set(name, values.get(values.size() - 1));
   break;
  // 去除值相同的
  case RETAIN_UNIQUE:
   headers.put(name, values.stream().distinct().collect(Collectors.toList()));
   break;
  default:
   break;
  }
 }      

如果請求中設定的Origin的值與我們自己設定的是同一個,例如生産環境設定的都是自己的域名xxx.com或者開發測試環境設定的都是*(浏覽器中是無法設定Origin的值,設定了也不起作用,浏覽器預設是目前通路位址),那麼可以選用RETAIN_UNIQUE政策,去重後傳回到前端。

如果請求中設定的Oringin的值與我們自己設定的不是同一個,RETAIN_UNIQUE政策就無法生效,比如 ”*“ 和 ”xxx.com“是兩個不一樣的Origin,最終還是會傳回兩個Access-Control-Allow-Origin 的頭。此時,看代碼裡,response的header裡,先加入的是我們自己配置的Access-Control-Allow-Origin的值,是以,我們可以将政策設定為RETAIN_FIRST ,隻保留我們自己設定的。

大多數情況下,我們想要傳回的是我們自己設定的規則,是以直接使用RETAIN_FIRST 即可。實際上,DedupeResponseHeader 可以針對所有頭,做重複的處理。

手動寫一個 CorsResponseHeaderFilter 的 GlobalFilter 去修改Response中的頭

@Component
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
 
    private static final Logger logger = LoggerFactory.getLogger(CorsResponseHeaderFilter.class);
 
    private static final String ANY = "*";
 
    @Override
    public int getOrder() {
        // 指定此過濾器位于NettyWriteResponseFilter之後
        // 即待處理完響應體後接着處理響應頭
        return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
    }
 
    @Override
    @SuppressWarnings("serial")
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        return chain.filter(exchange).then(Mono.fromRunnable(() -> {
            exchange.getResponse().getHeaders().entrySet().stream()
                    .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
                    .filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
                            || kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)
                            || kv.getKey().equals(HttpHeaders.VARY)))
                    .forEach(kv ->
                    {
                        // Vary隻需要去重即可
                        if(kv.getKey().equals(HttpHeaders.VARY))
                            kv.setValue(kv.getValue().stream().distinct().collect(Collectors.toList()));
                        else{
                            List<String> value = new ArrayList<>();
                            if(kv.getValue().contains(ANY)){  //如果包含*,則取*
                                value.add(ANY);
                                kv.setValue(value);
                            }else{
                                value.add(kv.getValue().get(0)); // 否則預設取第一個
                                kv.setValue(value);
                            }
                        }
                    });
        }));
    }
}      

此處有兩個地方要注意:

  1. 根據下圖可以看到,在取得傳回值後,Filter的Order 值越大,越先處理Response,而真正将Response傳回到前端的,是 NettyWriteResponseFilter, 我們要想在它之前修改Response,則Order 的值必須比NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER 大。
Spring Cloud Gateway 2.X 跨域時出現重複Origin的BUG

 修改後置filter時,網上有些部落格使用的是 Mono.defer去做的,這種做法,會從此filter開始,重新執行一遍它後面的其他filter,一般我們會添加一些認證或鑒權的 GlobalFilter ,就需要在這些filter裡用ServerWebExchangeUtils.isAlreadyRouted(exchange) 方法去判斷是否重複執行,否則可能會執行二次重複操作,是以建議使用fromRunnable 避免這種情況。

參考:

​​https://github.com/spring-cloud/spring-cloud-gateway/issues/728​​