后端工程师的「跨域」之旅(二)

iamwaiwai
发布于 2022-5-17 17:44
浏览
0收藏

3  后端配置


后端配置我尝试过两种方式,经过两个月的测试,都能非常稳定的运行。

 

  ◆ MND推荐的Nginx配置;


  ◆ SpringBoot自带CorsFilter配置。


▍MND推荐的Nginx配置


Nginx配置相当于在请求转发层配置。

location / {
     if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' '*';
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
        #
        # Custom headers and headers various browsers *should* be OK with but aren't
        #
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
        #
        # Tell client that this pre-flight info is valid for 20 days
        #
        add_header 'Access-Control-Max-Age' 1728000;
        add_header 'Content-Type' 'text/plain; charset=utf-8';
        add_header 'Content-Length' 0;
        return 204;
     }
     if ($request_method = 'POST') {
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
        add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
     }
     if ($request_method = 'GET') {
        add_header 'Access-Control-Allow-Origin' '*' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
        add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always;
     }
}

在配置Access-Control-Allow-Headers属性的时候,因为自定义的header包含签名和token,数量较多。为了简洁方便,我把Access-Control-Allow-Headers配置成 * 。

 

在Chrome和firefox下没有任何异常,但在IE11下报了如下的错:

 

Access-Control-Allow-Headers 列表中不存在请求标头 content-type。


原来IE11要求预检请求返回的Access-Control-Allow-Headers的值必须以逗号分隔。

 

▍SpringBoot自带CorsFilter


首先基础框架里默认有如下跨域配置。

public void addCorsMappings(CorsRegistry registry) {
    registry.addMapping("/**")
      .allowedOrigins("*")
      .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE")
      .allowCredentials(true)
      .allowedHeaders("*")
      .maxAge(3600);
}

可是部署完成,进入还是报CORS异常:后端工程师的「跨域」之旅(二)-鸿蒙开发者社区从nginx和tomcat日志来看,仅仅收到一个OPTION请求,springboot应用里有一个拦截器ActionInterceptor,从header中获取token,调用用户服务查询用户信息,放入request中。当没有获取token数据时,会返回给前端JSON格式数据。

 

但从现象来看CorsMapping并没有生效。

 

为什么呢?实际上还是执行顺序的概念。下图展示了 过滤器,拦截器,控制器的执行顺序。后端工程师的「跨域」之旅(二)-鸿蒙开发者社区DispatchServlet.doDispatch()方法是SpringMVC的核心入口方法。

// Determine handler for the current request.
mappedHandler = getHandler(processedRequest);
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
    return;
}
// Actually invoke the handler.
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());

那么CorsMapping在哪里初始化的呢?经过调试,定位于AbstractHandlerMapping。

protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request,
  HandlerExecutionChain chain, CorsConfiguration config) {
  if (CorsUtils.isPreFlightRequest(request)) {
   HandlerInterceptor[] interceptors = chain.getInterceptors();
   chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors);
  }
  else {
   chain.addInterceptor(new CorsInterceptor(config));
   }
  return chain;
 }

代码里有预检判断,通过 PreFlightHandler.handleRequest() 中处理,但是处于正常的业务拦截器之后。

 

  ◆ 最终选择CorsFilter 主要基于两点原因:

 

  ◆ 过滤器的执行顺序优先级最高;


通过调试CorsFilter的源码,发现源码有很多细节的处理。

private CorsConfiguration corsConfig() {
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.addAllowedOrigin("*");
    corsConfiguration.addAllowedHeader("*");
    corsConfiguration.addAllowedMethod("*");
    corsConfiguration.setAllowCredentials(true);
    corsConfiguration.setMaxAge(3600L);
    return corsConfiguration;
}
@Bean
public CorsFilter corsFilter() {
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", corsConfig());
    return new CorsFilter(source);
}

下面的代码里,allowHeader是通配符 * 的时候,CorsFilter在设置 Access-Control-Allow-Headers 的时候,会将 Access-Control-Request-Headers 以逗号拼接起来,这样就可以避免IE11响应头的问题。

public List<String> checkHeaders(@Nullable List<String> requestHeaders) {
   if (requestHeaders == null) {
      return null;
   }
   if (requestHeaders.isEmpty()) {
      return Collections.emptyList();
   }
   if (ObjectUtils.isEmpty(this.allowedHeaders)) {
      return null;
   }

   boolean allowAnyHeader = this.allowedHeaders.contains(ALL);
   List<String> result = new ArrayList<>(requestHeaders.size());
   for (String requestHeader : requestHeaders) {
      if (StringUtils.hasText(requestHeader)) {
         requestHeader = requestHeader.trim();
         if (allowAnyHeader) {
            result.add(requestHeader);
         }
         else {
            for (String allowedHeader : this.allowedHeaders) {
               if (requestHeader.equalsIgnoreCase(allowedHeader)) {
                  result.add(requestHeader);
                  break;
               }
            }
         }
      }
   }
   return (result.isEmpty() ? null : result);
}

浏览器的执行效果如下: 

后端工程师的「跨域」之旅(二)-鸿蒙开发者社区

4  preflight响应码:200 vs 204


后端配置完成之后,团队里的小伙伴问我:“勇哥,那预检请求返回的响应码到底是200还是204呀?”。这个问题真把我给问住了。

 

我司的API网关的预检响应码是200,CorsFilter预检响应码也是200。

 

MDN给的示例预检响应码全部是204。

 

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
后端工程师的「跨域」之旅(二)-鸿蒙开发者社区我只能采取Google大法,赫然发现大名鼎鼎的API网关Kong的开发者也针对这个问题有一番讨论。后端工程师的「跨域」之旅(二)-鸿蒙开发者社区1.MDN曾经推荐的preflight响应码是200 ,所以Kong也和MDN同步成200;

 

The page was updated since then. See its contents on Sept 30th, 2018:

https://web.archive.org/web/20180930031917/https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request


2.后来MDN将响应码修改204,于是Kong的开发者争论要不要和MDN保持同步。

 

争论的核心点在于:有没有迫切的必要。200响应码运行得很好,似乎也将永远正常运行下去。而更换成204,不确定是否有隐藏问题。

 

3.说到底,框架开发者还是依赖于浏览器的底层实现。在这个问题上,没有足够权威的资料能够支撑框架开发者,而各个知识点都散落在网络的各个角落,充斥着不完整的细节和部分解决方案,这些都让框架开发者非常困惑。

 

最后,Kong的源码里预检响应码仍然是200,并没有和MDN保持同步。

 

我仔细查看了各大主流网站,95%预检响应码是200。而经过两个多月的测试,Nginx配置预检响应码204,在主流的浏览器Chrome , Firefox ,  IE11 也没有出现任何问题。

 

所以,200 works everywhere, 而204在当前主流的浏览器里也得到非常好的支持

 

5  Chrome: 非安全私有网络


本以为跨域问题就这样解决了。没想到还是有一个小插曲。

 

产品总监需要给客户做演示,我负责搞定演示环境。申请域名,准备阿里云服务器,应用打包,部署,一切都很顺利。

 

可是在公司内网访问演示环境,有一个页面一直报CORS报错,报错内容类似下图:后端工程师的「跨域」之旅(二)-鸿蒙开发者社区跨域的错误类型是:InsecurePrivateNetwork。

 

这和原来遇到的跨域错误完全不一样,我心里一慌。马上Google , 原来这是chrome更新到94之后新的特性,可以手工关闭这个特性。

 

1.打开 tab 页面  chrome://flags/#block-insecure-private-network-requests

 

2.将其  Block insecure private network requests 设置为 Disabled, 然后重启就行了, 这样子就相当于把这个功能禁用掉。
后端工程师的「跨域」之旅(二)-鸿蒙开发者社区但这样是治标不治本呀。有点诡异的是,当我们不在公司内网访问演示环境的时候,演示环境完全正常,出错的页面也能正常访问。

仔细看官方的文档,CORS-RFC1918 指出如下三种请求会受影响。

 

  ◆ 公共网络访问私有网络;
  ◆ 公共网络访问本地设备;
  ◆ 私有网络访问本地设备。
后端工程师的「跨域」之旅(二)-鸿蒙开发者社区这样,我把问题定位在这个出错的第三方接口地址上。公司很多产品都依赖这个接口服务。当在公司内网访问的时候,该域名映射地址类似:172.16.xx.xx。

 

而这个ip正好是rfc1918上规定的私有网络。

10.0.0.0     -  10.255.255.255  (10/8 prefix)
172.16.0.0   -  172.31.255.255  (172.16/12 prefix)
192.168.0.0  -  192.168.255.255 (192.168/16 prefix)

内网通过Chrome访问这个页面的时候,会触发非安全私有网络拦截。

 

如何解决呢?官方给出的方案分两步走:

 

1.私有网络只能通过Https来访问;


2.未来,添加特定的预检头,比如说:Access-Control-Request-Private-Network等。


当然还有一些临时方法:

 

  ◆ 关闭Chrome该特性

  ◆ ;换用其他浏览器比如Firefox;

  ◆ 关闭网络内网开手机热点;
  ◆ 修改本地host绑定外网ip。


基于官方的方案 ,生产环境完全使用Https,公司内网访问就没有出现这样的跨域问题了。

 

6 复盘

后端工程师的「跨域」之旅(二)-鸿蒙开发者社区 美团Shepherd API网关的整体架构


API网关非常适合当前产品的架构。架构设计之初,系统多端都会调用我司的API网关。API网关可以SAAS部署和私有化部署,有单独的域名,提供完善的签名算法。考虑到上线时间节点,团队成员对于API网关的熟悉程度以及多套环境部署投入时间成本,为了尽快交付,从架构层面,我做了一些平衡和妥协。

 

接入层调用的接口域名统一使用 api.training.com这个独立的域名,通过Nginx来配置请求转发。同时,我和前端Leader统一了前后端协议,保持和我司API网关一致,为后续切回API网关做前置准备。

 

API网关可以做鉴权,限流,灰度等,同时可以配置CORS。内部服务端不用特别关注跨域这个问题。后端工程师的「跨域」之旅(二)-鸿蒙开发者社区

腾讯API网关的配置界面


同时,在解决跨域的问题过程中,我的心态也发生了变化。从最初的轻视,到逐渐沉下心来,一步步理解CORS的原理,分清楚不同解决方案的优缺点,事情也就慢慢顺遂起来。我也观察到:”有的项目组已经反馈过Chrome非安全私有网络问题,并给出了解决方案。对于技术管理者来讲,一定要重视项目中反馈的问题,做好梳理分析,整理预案。这样当同类问题出现时,也会条理有序“。

 

7  写到最后


2016年,我参加左耳朵耗子陈皓老师技术演讲,他给我们讲了一个故事。

 

故事的大概是:“公司软件出现莫名BUG,用户的费用扣了,但调用第三方接口的时候经常出现网络问题。公司当时最厉害的人查了一周也没有解决,而陈皓老师正在看《TCP/IP 详解》这本书, netstat 一看,连接的状态是 CLOSE_WAIT ,意思是对方断开了连接,大概率估计是对方系统的问题。于是他去了对方那边帮他们看了一下代码,果然是判断条件出了问题,导致应用直接断开了链接。而这个问题只花了不到两个小时就解决了”。

 

当我想起陈皓老师的这个故事,回顾自己的跨域之旅,我深深的觉得细节是魔鬼,而解决问题也许就在某个不经意的细节里。

标签
已于2022-5-17 17:44:06修改
收藏
回复
举报
回复
    相关推荐