同源策略与CORS

753 阅读10分钟

关于跨域,这是一个在面试和日常工作中经常遇到的一个问题。作为一个前端工程师,当涉及到与跨域相关的问题的时候,如果后端工程师不是特别了解,就需要来协助他完成对应的配置工作;在面试的时候,也经常会被问到什么是跨域,怎么去解决。
在Web页面中可以随意加载跨域的图片、视频、样式等资源,但是AJAX请求通常会受限于浏览器的同源安全策略,而被限制发送跨域请求。 在2014年,W3C发布了CORS Recommendation, 形成了统一的标准,它允许更方便的跨域资源共享,浏览器可以向不同源的的服务器,发出XMLHttpRequest请求,来避免 AJAX请求只能同源使用所带来的的限制。

首先来考虑一个问题:什么是同源?为什么浏览器要有一个同源策略?

关于同源策略(Same origin policy)

什么是同源

同源策略在1995年由Mozilla的前身Netscape公司引入,这也是现在所有浏览器都实行的策略。它的含义包括:
如果两个页面的URL的协议端口号(如果有指定的话)和主机名都相同的话,那它们就拥有相同的源。
http://www.foo.ample.com/bar 为例:

URL是否同源原因
www.foo.example.com/baz同源只有路径不同
www.foo.example.com/baz不同源协议不同
www.foo.example.com:8000/bar不同源端口号不同(http默认为80端口)
www.baz.example.com/bar不同源域名不同

为什么需要同源策略

同源策略其实就是通过限制从一个源加载的文档和脚本与另外一个源进行交互,以减少可能的攻击(比如CSRF)的安全机制,以保护用户信息的安全,避免恶意网站窃取用户数据。
因为浏览器可以从多个源中去加载和显示资源,我们可以同时打开浏览器的多个tab,或者查看一个嵌入了多个来自不同站点的资源的站点的网站。如果不限制这些源之间的交互,如果有一个脚本被攻击,那么用户浏览器上的所有内容都可能会暴露给攻击者。
如果要进行跨域请求,要先了解web浏览器为什么要实现同源策略,以及浏览器在api上的一些限制,例如:

  • XMLHttpRequestFetch
    这两个API都遵循同源策略。这也就意味着使用这些API的web应用都只能从加载该应用的同一个域来请求http资源。
    如果想进行跨域请求,那就有必要去进行一些特殊的设置。
  • cookies
    cookies只有在同源的网页间才能共享。如果要在跨域请求中携带cookies,也需要进行特殊的处理。

什么是CORS

既然讲过了同源策略,那不同源的浏览器页面和服务器端之间的请求就属于跨域请求了。

为什么需要CORS

  • 现代的前后端分离开发,前后端应用如果在不同的端口上启动,就会带来跨域的问题
  • 不同的资源分布在不同的服务器和域名下,A域下的页面访问B域的服务就会出现跨域的问题
  • JSONP只支持 GET 请求,跨域请求方式受限

但是,虽然CORS机制极大的方便了我们进行跨域请求,但是只有在绝对必须而且对源、方法、请求头/响应头非常确定的情况下,才适合进行CORS请求。

CORS的支持

CORS需要浏览器和服务器同时支持,现代浏览器(包括IE10及以上版本)都支持跨域, IE8和IE9可以通过XDomainRequest对象进行部分支持。

浏览器

在整个CORS的通信过程中,浏览器会自动完成,对于前端开发来说,CORS的AJAX请求和同源的AJAX请求没有什么区别,浏览器一旦发现AJAX请求是跨域请求,就会自动去附加一些请求头信息。有时候会多发一次请求(Preflight request).

服务器

服务器端是实现CORS通信的关键,服务器需要在收到跨域请求的时候,进行相应的配置,来保证浏览器可以正确的接收和处理跨域请求的响应,包括:

  • 能够进行跨域请求的域
  • 允许使用的HTTP方法(GET/POST/DELETE等等)
  • 请求中允许携带的请求头信息
  • 请求是否可以携带cookies信息
  • 响应中携带的响应头信息

两类请求方式

在工作中经常遇到的如下的请求,在原本的POSTDELETE请求之前多发了一次OPTIONS请求,这称为非简单请求 (Not So Simple Request), 这次OPTIONS请求通常我们也称之为预检请求 (Preflight Request).
与之相对应的还有一种简单请求 (Simple Request), 它可以通过遵从一些请求方法、请求头和响应头的约定,来减少这一次请求的过程。

简单请求 (Simple Request)

简单请求的条件包括简单请求方法(simple method)和简单请求头信息(simple header)两大条件。 简单请求方法:

  • HEAD
  • GET
  • POST

简单请求头信息:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

其中Content-Type只能为application/x-www-form-urlencoded, multipart/form-datatext/plain.

同时,W3C标准对简单请求的响应头(simple response header)也做了定义:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

介绍完了一些关于简单请求的基本信息,让我们来看看它是怎么运作的:

  1. 当浏览器发现我们的请求是跨域请求的时候,它会在请求头自动附加一个Origin字段
GET /cors HTTP/1.1
Host: http://test.bar.com/apis
Accept-Language: zh-CN
Connection: keep-alive
//  这就是浏览器自动附加的Origin字段
Origin: http://test.foo.com

这里附加的Origin字段用来表明本次请求来自于哪个源,服务器收到这个跨域请求后,可以根据它来判断是否接受这次请求。
2. 当服务器发现请求头中的Origin字段不在许可的源的范围内时,会抛出一个错误,从而被请求库或方法的处理错误的方法捕获到;只有请求的源在许可范围内时,服务器才会返回一个正常的HTTP响应,并在响应头中附加如下信息:

  • Access-Control-Allow-Origin
    该字段分隔,其值为请求时Origin字段的值,或者为通配符*,表示接受来自任何源的请求。
  • Access-Control-Allow-Credentials
    该字段可选,布尔类型,为true的话表示可以请求可以使用credentials(包括cookies, authorization headers等). 如果这是在预检请求的响应头中的话,则标示真实的请求可以携带credentials.
    需要注意的是:如果要发送cookies的话,那么Access-Control-Allow-Origin的值将只能指定单一域名
  • Access-Control-Expose-Headers
    简单请求的响应头在上面已经有过介绍,只包括上述的6个字段。如果我们想拿到其它字段,就需要在这里去指定。

非简单请求 (Not so simple Request)

简单请求虽然便利了我们进行跨域请求的方式,但是我们往往会遇到一些特殊请求的场景,比如:

  • 需要使用PUT/PATCH/DELETE等请求方法
  • 需要使用Content-Type: application/json

在非简单请求, 会在正式请求(Actual Request) 之前,进行一次OPTIONS请求,称为预检请求 (Preflight Request). 浏览器通过这次请求去询问服务器:

  • 请求的域名是否在服务器许可的源范围之内
  • 请求的方法是否在服务器接受的方法范围之内
  • 请求头信息是否在服务器接受的请求头信息之内
  • 请求是否可以携带credentials(如果需要的话)
  1. 浏览器在发送预检请求时,除了Origin字段,还会在请求头中附加两个跨域请求的特殊字段:
  • Access-Control-Request-Method
    该字段必填。用于通知服务器在正式的请求中将会使用哪种HTTP方法。
  • Access-Control-Request-Headers
    该字段用来通知服务器在正式的请求中将会采用哪些请求头。其值为正式请求中的一系列请求头字段名,用逗号。

此外,由于跨域请求默认不发送cookies和http认证信息,如果想把cookies发送到服务器,除了之前提到的服务器需要响应 Access-Control-Allow-Credentials: true 以外,开发者需要在AJAX请求中设置 withCredentials 属性:

//  给XMLHTTPRequest设置withCredentials: true
let xhr = new XMLHttpRequest()
xhr.withCredentials = true
//  在使用axios的时候,将config中的withCredentials设置为true
axios.get(api_url, { withCredentials: true })
  1. 服务器收到预检请求后,会检查Origin/Access-Control-Request-Method/Access-Control-Request-Headers字段,确认是否允许该跨域请求,并返回响应。响应中将会包含以下响应头:
  • Access-Control-Allow-Methods
    用来标示服务器支持的所有的跨域请求的方法,用逗号分隔。如果正式请求的方法没有被包含在其中,请求将会抛出错误。
  • Access-Control-Allow-Headers
    用来标示服务器支持的所有的头信息字段,用逗号分隔。如果请求中包含了Access-Control-Request-Headers,则必须响应该字段。如果正式请求的请求头没有被包含在其中,请求将会抛出错误。
  • Access-Control-Allow-Credentials
    该字段与简单响应时的含义相同。
  • Access-Control-Max-Age
    该字段可选。用来指定本次预检请求的有效期,单位为秒,表示在在该有效期内,不需要再发出一条预检请求。
  1. 只有服务器对预检请求有肯定答复后,浏览器才会发出正式请求,不然就会报错。一旦服务器通过了预检请求,以后浏览器每次进行跨域请求就会和简单请求一样。浏览器会在请求头中附加Origin字段,而服务器会在响应头中附加Access-Control-Allow-Origin字段。

其他

code 501, message Unsupported method ('OPTIONS')

可能的原因如下:响应头中信息的Access-Control-Allow-Headers等配置有问题,需要增加允许请求头值的范围。
建议在后端自定义配置CORS的filter,来解决这些问题,例如:

HttpServletResponse httpResponse = (HttpServletResponse) response;
HttpServletRequest httpRequest = (HttpServletRequest) request;
httpResponse.setHeader("Access-Control-Allow-Origin", httpRequest.getHeader(HttpHeaders.ORIGIN));
httpResponse.setHeader("Access-Control-Allow-Methods", "GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH");
httpResponse.setHeader("Access-Control-Max-Age", "3600");
httpResponse.setHeader("Access-Control-Allow-Headers", "*, Authorization, Origin, User-Agent, Referer, Accept, Content-type, Cache-Control, Pragma, Expires");

前后端分离开发中的跨域解决

比如前端的服务运行在3000端口上,后端的服务运行在8000端口上。这时候我们可以通过域名配置和反向代理的方式来解决不必要的跨域。比如前端的域名为 test.bar.com,后端的域名为 test.bar.com/apis,这样就避免了跨域问题。
如果前后端都在同一台服务器上,可以在nginx上作如下配置:

server {
  listen 80;
  server_name: test.bar.com;
  location / {
    proxy_pass http://localhost:3000;
  }
  location /apis {
    proxy_pass http://localhost:8000;
  }
}

参考资料

Cross-origin resource sharing, wikipedia
Cross-Origin Resource Sharing(CORS), MDN
Cross-Origin Resource Sharing, W3C
Same-origin policy, MDN