(转) 跨域分析和常见解决办法

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

跨域,对后端工程师来说,可谓既熟悉又陌生。

这两个月我以架构师的角色参与一款教育产品的孵化,有了一段难忘的跨域之旅

写这篇文章,我想分享我在跨域这个知识点上的经历和思考,希望对大家有所启发。

1 遇见跨域

产品有多端:机构端,局方端 ,家长端等 。每端都有独立的域名,有的是在 PC 上访问,有的是通过微信公众号来访问,有的是扫码后 H5 展现。

接入层调用的接口域名统一使用 api.training.com这个独立的域名,通过 Nginx 来配置请求转发。

通常,我们提到的跨域指:CORS

CORS 是一个W3C标准,全称是 "跨域资源共享"(Cross-origin  resource  sharing), 它需要浏览器和服务器同时支持他,允许浏览器向跨源服务器发送XMLHttpRequest请求,从而克服 AJAX 只能同源使用的限制。

那么如何定义同源呢?我们先看下一个典型的网站的地址:

同源是指:协议、域名、端口号完全相同

下表给出了与 URL http://www.training.com/dir/page.html 的源进行对比的示例:

当用户通过浏览器访问应用(http://admin.training.com)时,调用接口的域名非同源域名 (http://api.training.com),这是显而易见的跨域场景。

2  CORS 详解

跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。

规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨域请求。

服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证相关数据)。

2.1 简单请求

当请求同时满足如下条件时,CORS 验证机制会使用简单请求, 否则 CORS 验证机制会使用预检请求。

  1. 使用 GET、POST、HEAD 其中一种方法;

  2. 只使用了如下的安全首部字段,不得人为设置其他首部字段;

  • Accept

  • Accept-Language

  • Content-Language

  • Content-Type 仅限三种之一:text/plain,multipart/form-data,application/x-www-form-urlencoded:

  • HTML 头部 header field 字段:DPR、Download、Save-Data、Viewport-Width、WIdth

  1. 请求中的任意 XMLHttpRequestUpload  对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问;

  2. 请求中没有使用 ReadableStream 对象。

简单请求模式,浏览器直接发送跨域请求,并在请求头中携带 Origin 的头,表明这是一个跨域的请求。服务器端接到请求后,会根据自己的跨域规则,通过 Access-Control-Allow-Origin 和 Access-Control-Allow-Methods 响应头,来返回验证结果。

应答中携带了跨域头 Access-Control-Allow-Origin。使用 Origin 和 Access-Control-Allow-Origin 就能完成最简单的访问控制。本例中,服务端返回的 Access-Control-Allow-Origin: * 表明,该资源可以被任意外域访问。如果服务端仅允许来自 http://admin.training.com 的访问,该首部字段的内容如下:

Access-Control-Allow-Origin: http://admin.training.com


现在,除了 http://admin.training.com,其它外域均不能访问该资源。

2.2 预检请求

浏览器在发现页面发出的请求非简单请求,并不会立即执行对应的请求代码,而是会触发预先请求模式。预先请求模式会先发送 preflight request(预先验证请求),preflight request 是一个 OPTION 请求,用于询问要被跨域访问的服务器,是否允许当前域名下的页面发送跨域的请求。在得到服务器的跨域授权后才能发送真正的 HTTP 请求。

OPTIONS 请求头部中会包含以下头部:

服务器收到 OPTIONS 请求后,设置头部与浏览器沟通来判断是否允许这个请求。

如果 preflight request 验证通过,浏览器才会发送真正的跨域请求。

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 ,意思是对方断开了连接,大概率估计是对方系统的问题。于是他去了对方那边帮他们看了一下代码,果然是判断条件出了问题,导致应用直接断开了链接。而这个问题只花了不到两个小时就解决了”。

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

推荐

主流 Java 进阶技术(学习资料分享)

Java 面试题宝典

加入 Spring 技术开发社区

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下 “在看”,加个 “星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点 “在看” 支持我们吧

posted @ 2022-01-05 14:17  托马斯布莱克  阅读(408)  评论(0编辑  收藏  举报