跨域问题的一次深入研究

9

前言

最近在业务代码中深受跨域问题困扰,因此特别写一篇博客来记录一下自己对跨域的理解以及使用到的参考资料。本文的项目背景基于vue+vuex+axios+springboot。涉及以下内容:

  • 何为跨域
  • HTTP跨域的请求究竟长啥样,里面的参数分别代表什么意思
  • SpringBoot配置跨域请求

如果对跨域有所了解的盆友可以直接跳到SpringBoot配置部分查看具体配置,或者是参考文章末尾Spring官网对CORS配置的博客链接。

什么是跨域

跨域是指当一个资源从与该资源本身所在的服务器不同的域或端口请求一个资源时,资源会发起一个跨域 HTTP 请求。这里盗用MDN上的一张图:

clipboard.png
当一个域名向另一个不同的域名发起请求时,这时就产生了跨域问题。
那么为什么会出现跨域这样的概念呢?这就要提到之前规定的same origin policy。以下是引自维基百科的对于同源政策的描述:

An origin is defined by the scheme, host, and port of a URL. Generally speaking, documents retrieved from distinct origins are isolated from each other. For example, if a document retrieved from http://example.com/doc.html tries to access the DOM of a document retrieved from https://example.com/target.html, the user agent will disallow access because the origin of the first document, (http, example.com, 80), does not match the origin of the second document (https, example.com, 443).

总而言之,同源就是指拥有同样的schema,主机和端口号的URL,不满足以上三点的任何一点都代表着这两个URL非同源,它们之间的相互访问就会产生跨域问题。

这里再借用MDN上的URL是否同源的例子:

clipboard.png

而在HTTP访问中,又有了些许的变化。比如我们通常会从CDN上获取CSS,JS等静态资源,而这些静态资源的域名和当前的域并不同源,但是HTTP允许这样的跨域访问。因此,我们可以将HTTP上的跨域分为三类:

  • 通常允许跨源写入。比如链接,重定向和表单提交。某些特殊的HTTP请求可能需要预检(preflight),后面将会详细介绍这个词。
  • 内嵌式跨域通常也是允许的。比如<link rel="stylesheet" href="...">获得CSS文件,<img>标签引入另一个源的图片
  • 通常不允许跨源读取,但读访问通常通过嵌入泄露。例如,您可以读取嵌入式图像的宽度和高度,以及嵌入式脚本的操作。前端可以通过嵌入式跨域变相实现跨域读取。前端跨域的方法非常多,不过不是本文的重点,所以不详细描述。

为什么会出现同源政策

这里简单介绍一下有名的CSFR攻击来说明同源政策的目的。
这里引用维基百科对跨站请求攻击的解释:

跨站请求攻击,简单地说,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一些操作(如发邮件,发消息,甚至财产操作如转账和购买商品)。由于浏览器曾经认证过,所以被访问的网站会认为是真正的用户操作而去执行。这利用了web中用户身份验证的一个漏洞:简单的身份验证只能保证请求发自某个用户的浏览器,却不能保证请求本身是用户自愿发出的。

引用网上的一张图片:
clipboard.png

简单的解释一下跨站请求的实现,维基百科上也有非常详细的例子。
假设现在用户在网页上进行转账,只有通过身份验证的用户才可以进行该操作。假设服务器是通过这样的一个URL http://bank.example/withdraw?account=a&amount=1000000&for=b实现a向b转账100000块的业务。因为该请求会携带用户的身份认证信息,因此它能够通过服务器的认证并实现操作。但是这时恶意用户c希望用这样一个形式的URLhttp://bank.example/withdraw?account=a&amount=1000000&for=c
因为c自己并不具有a的session,因此他会通过别的方式诱惑a用户执行这个操作。比如它会通过发送恶意邮件的方式骗a点击上面的超链接。a如果此时并没有退出bank.example的登录即其session信息未被清空,那么a将成功通过服务器的认证进行转账,实现了c的“心愿”。其它的还有诸如在用户进入恶意网站后利用js脚本自动提交表单向bank.example发出带有a的session的post请求等等。

同源政策将会确保网站a拒绝来自网站b的请求。

那为什么又需要跨域

当前端框架兴起之后,前后端彻底分离的开发方式渐渐流行。前端和后端往往部署在不同的域名之上。前端通过访问后端的API获取数据,渲染前端界面,甚至进行路由跳转。这通常意味着前后端会出现不同源的问题。因为即使部署在同一台主机上,二者也属于不同的端口。那么我们就需要某种策略使得跨域请求能够通过。支持跨域的方式有很多,下文主要介绍后端Spring Boot配置支持跨域访问。

跨域访问的HTTP报文

之前配置Spring Boot跨域的时候,我都是直接从网上抄一段这样的代码:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    ......

    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/***")
                .allowedHeaders("**")
                .allowedMethods("GET", "POST")
                .allowedOrigins("*");

    }

}

一开始在开发过程中,这段代码并没有问题。但是在试图接入登录业务之后出现了问题。因为采用Dubbo进行微服务开始,我们决定将登录作为一个单独的业务独立部署在另一个容器中。登录业务的基本流程是访问登录容器,登录成功后返回一个token存储在服务器的localStorage中。之后每次访问别的服务时都会在header中携带这个token,服务利用拦截器对token进行解析,判断其是否合法以及是否生效,如果合法则将解析结果放入request中传递给后面的Controller。

在上面这个配置的基础上出现了几个问题:

  1. 在发送请求前,会发送preflight的OPTION请求来判断服务器是否支持该域的跨域请求以及支持的跨域方法,但是该配置并不支持跨域的OPTION请求,从而导致OPTION方法无法通过,进而无法发送真正的GET或是POST请求
  2. 针对1中的问题开放OPTION请求之后,如果不进行认证就去访问需要认证的业务,虽然获得了401的状态码,但是会出现跨域请求失败的问题。如果你去查看该请求的响应头,会发现响应header中确实没有access-control-allow-origin字段!也就是说响应被拦截器拦截,甚至没有进入跨域访问的响应逻辑。而我使用axios时因为这个响应报文最后被认为是跨域问题,无法从error中获得401的状态码。

clipboard.png

总之,因为不知道一个真正的跨域请求的报文应该是什么样子的,所以盲目的折腾了半天,甚至没能将问题定位到后端的跨域配置。所以,现在来看一下真正的跨域请求报文究竟是什么样子的,来了解一下跨域的原理。

跨域报文

preflight

在次之前,先了解一下preflight。
我们去查看浏览器发出的跨域请求时,经常会看到一个OPTION报文,它的url和真正的GET或是POST请求的URL相同。这个OPTION请求就是传说中的preflight请求。preflight请求是为了询问服务器该跨域请求是否可以被识别或是被允许。

preflight报文通常长成这样:

OPTIONS /resource/foo 
Access-Control-Request-Method: DELETE 
Access-Control-Request-Headers: origin, x-requested-with
Origin: https://foo.bar.org

如果允许来自该IP的跨域访问,服务器会用Access-Control-Allow-Origin头字段说明允,并在Access-Control-Allow-Methods指明允许的方法。preflight响应报文通常长成这样:

HTTP/1.1 200 OK
Content-Length: 0
Connection: keep-alive
Access-Control-Allow-Origin: https://foo.bar.org
Access-Control-Allow-Methods: POST, GET, OPTIONS, DELETE
Access-Control-Max-Age: 86400

这里Access-Control—Max-Age指定了在86400s内无需为该URL发送preflight请求。
至于为何需要preflight请参考reference中的文章。

CORS报文

并不是所有的请求都需要发送preflight请求,服务器面对简单请求会直接返回Access-Control-Allow-Origin响应头来说明它的跨域访问是否通过,如果通过,则会在响应体中直接携带数据。请求和响应报文如下:

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Referer: http://foo.example/examples/access-control/simpleXSInvocation.html
Origin: http://foo.example


HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2.0.61 
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[xml]

服务器会检查origin字段的URL是否允许跨域请求。可以看到该服务器允许来自一切IP的跨域访问,因为它返回的响应头为Access-Control-Allow-Origin: *
你会发现,这里的请求和一般的HTTP请求并没有太大的差别。

那么,什么是简单请求呢?

clipboard.png
满足以上要求的则为简单请求。而通常前后端分离的服务之间会通过json形式的数据进行沟通,即content-type为application/json。而这种形式不符合简单请求的定义,因此需要使用option请求进行预检。

复杂请求的预检请求报文如下:

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10.5; en-US; rv:1.9.1b3pre) Gecko/20081130 Minefield/3.1b3pre
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

请求头中有两个字段比较特殊。Access-Control-Request-Method说明真正的跨域请求的方法,这里是POST方法,而Access-Control-Request-Headers则说明请求头中包含哪些非简单字段。服务器端会根据自身的配置查看是否支持包含这些非简单字段的请求,如果不包含,则该跨域请求会被拒绝。

预检响应报文如下:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

这里Access-Control-Allow-Headers说明了服务器支持的跨域请求的这些字段。

之后服务器会发送真实的请求,服务器会对之响应,其响应头中会包含Access-Control-Allow-Origin字段。

身份认证

Spring-Boot 配置

现在我们再来看一下之前的配置:

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {
    ......

    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/***")//对/api/**进行跨域配置
                .allowedHeaders("**")//允许所有的非简单请求头
                .allowedMethods("GET", "OPTIONS", "POST") //允许三种方法
                .allowedOrigins("*");//允许来自所有域的请求

    }
}

当然这种全部符合的通配符并不是一个很好的选择,我们应当限制跨域请求的形式,从而拒绝不符合要求的请求。

第二种配置是采用Filter的形式进行配置。位于最前面的Filter会在请求进入任何其它位置之前对其进行处理。

@Configuration
public class MyConfiguration {

    @Bean
    public FilterRegistrationBean corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://domain1.com");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);
        FilterRegistrationBean bean = new FilterRegistrationBean(new CorsFilter(source));
        bean.setOrder(0);
        return bean;
    }
}

这里跟上面的配置意思基本相同,区别在于这里引入了setAllowCredential配置。它代表服务器支持跨域时携带认证信息。需要注意的是,如果开启这个配置,则allowedOrigins不可以为*。

Reference

springboot设置cors跨域请求的两种方式
spring官网-设置允许跨域请求
MDN Http 控制访问
MDN Same Origin Policy
What's the motivation of preflight

clipboard.png

若想了解更多技术资讯、面试教程以及互联网公司的内推信息,欢迎关注我的公众号!


如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的

javascriptuser · 2018年05月24日

请教题主一个问题。
页面发起的请求被 服务器 redirect了,算不算跨域?

回复

0

唔...重定向是指服务器收到当前请求返回一个3xx的资源变更指令,客户端再去访问这个新的地址。如果这个新的地址和当前的域不属于同一个域的话,使用ajax访问这个地址应该是属于跨域的,解决方法是直接使用浏览器去跳转,就不会有跨域问题了

raledong 作者 · 2018年05月24日
0

我没有试过,暂时还不清楚

javascriptuser · 2018年05月25日
载入中...