关于跨域:后端工程师的跨域之旅

39次阅读

共计 8641 个字符,预计需要花费 22 分钟才能阅读完成。

跨域,对后端工程师来说,堪称既相熟又生疏。

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

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

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
  3. 申请中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象能够应用 XMLHttpRequest.upload 属性拜访;
  4. 申请中没有应用 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…

我只能采取 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/2…

  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 报错,报错内容相似下图:

跨域的谬误类型是:<font color=”red”>InsecurePrivateNetwork</font>。

这和原来遇到的跨域谬误齐全不一样,我心里一慌。马上 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 复盘

API 网关非常适合以后产品的架构。架构设计之初,零碎多端都会调用我司的 API 网关。API 网关能够 SAAS 部署和私有化部署,有独自的域名,提供欠缺的签名算法。思考到上线工夫节点,团队成员对于 API 网关的相熟水平以及多套环境部署投入工夫老本,为了尽快交付,从架构层面,我做了一些均衡和斗争。

接入层调用的接口域名对立应用 api.training.com这个独立的域名,通过 Nginx 来配置申请转发。同时,我和前端 Leader 对立了前后端协定,放弃和我司 API 网关统一,为后续切回 API 网关做前置筹备。

API 网关能够做鉴权,限流,灰度等,同时能够配置 CORS。外部服务端不必特地关注跨域这个问题。

同时,在解决跨域的问题过程中,我的心态也产生了变动。从最后的鄙视,到逐步沉下心来,一步步了解 CORS 的原理,分分明不同解决方案的优缺点,事件也就缓缓顺遂起来。我也察看到:”有的项目组曾经反馈过 Chrome 非平安公有网络问题,并给出了解决方案。对于技术管理者来讲,肯定要器重我的项目中反馈的问题,做好梳理剖析,整顿预案。这样当同类问题呈现时,也会条理有序“。

7 写到最初

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

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

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


如果我的文章对你有所帮忙,还请帮忙 点赞、在看、转发 一下,你的反对会激励我输入更高质量的文章,非常感谢!

正文完
 0