×

跨域 与 CORS 探秘

96
赵团结
2016.11.25 16:36* 字数 2004

跨站请求

一个资源会发起一个跨域HTTP请求(Cross-site HTTP request), 当它请求的一个资源是从一个与它本身提供的第一个资源的不同的域名时 。

这句话引用自MDN对跨域请求的解释,虽然并不是那么通顺。
跨站请求不光是根域名不同,除此之外:

  • 子域不同
  • 端口号不同
  • 协议不同

都称属于跨站请求(Cross-site HTTP request)。

请求的分类

那么,什么是资源引用类请求呢?我们将网页中的请求大致分两类:

  • 资源引用
  • 请求

资源引用指的是我们网页上的一些资源文件,例如通过<img/>标签来加载图片,而标签上的src描述了源地址。常见的还有:

  • <script/>,最常用的就是js文件
  • <link/>,常用来引入css文件
  • Web字体(通过CSS中@font-face引入)(注1)
  • <iframe/>,用来引入其他地址

当网页使用了这些资源类的跨站请求时,不会产生跨域限制。而非资源请求,通常是指使用XMLHttpRequestFetch发起HTTP请求,当请求跨站时,会产生跨域问题。

注1:实际上,@font-face可以通过增加Access-Control-Allow-*头来进行访问控制,也就是可以产生跨域限制。

开始测试

这里我在本地建立了两个服务器:

  • 网站服务器,所有的请求由这里发出。 - http://localhost:3000
  • API Server,用来接收请求。- http://localhost:3001

这两个域名符合跨站条件,即端口号不同,按照上文所说,由xhrfetch发出的请求会导致跨域问题。

下面我们来验证这一点,其中,所有的跨站请求使用fetch对象发起。

简单请求

这里的简单请求,是指:

  • 只使用GET, HEAD, POST方法。
  • Content-Type只限于:applcation/x-www-form-urldecodedmultipart/form-datatext/plain中的一种。
  • 不能自定义headers。例如X-ModifiredX-Requested-With
fetch('http://localhost:3001/user', {
  method: 'GET'
})

简单请求示例

基本流程:
客户端会发送一个“简单请求”,包含资源路径、Origin等信息,这里,我在服务器端返回了一个Access-Control-Allow-Origin: *,这表明我的服务器接受来自任何站点的跨站请求。如果希望只允许来自http://localhost:3001的请求,可以返回:
Access-Control-Allow-Origin: http://localhost:3001

预请求(options)

不同于简单请求,“预请求”是以OPTIONS为请求方法,来查询服务器的访问控制信息,但并不会承载任何数据。

不满足简单请求的条件,都会额外创建一个预请求。

我希望user接口去登录:

fetch('http://localhost:3001/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest'
  },
  body: JSON.stringify({login: 'root', password: '123'})
})

首先,客户端会创建一个POST请求,指定类型为application/json,同时自定义了header的X-Requested-With。这时我们属于非简单请求,客户端会预先创建一个OPTIONS请求到服务器:

// 客户端发起
OPTIONS /user HTTP/1.1
Access-Control-Request-Headers: content-type, x-requested-with
Access-Control-Request-Method: POST
Origin: http://localhost:3000

// 服务器收到并返回
HTTP/1.1 200 OK
Access-Control-Allow-Headers:content-type, x-requested-with
Access-Control-Allow-Origin: http://localhost:3000
Connection: keep-alive
Content-Length: 4
Content-Type: application/json;charset=utf-8
Date: Fri, 25 Nov 2016 07:16:29 GMT

表明了该服务器只接受来自localhost:3000的请求。此时客户端的请求条件满足服务器所支持的。
紧接着浏览器又发起一个POST请求:

// 客户端发起 并附加数据
POST /user HTTP/1.1
content-type: application/json
Origin: http://localhost:3000
x-requested-with: XMLHttpRequest

{"login":"root","password":"123"}

// 服务器返回
HTTP/1.1 200 OK
Access-Control-Allow-Headers: content-type, x-requested-with
Access-Control-Allow-Origin: http://localhost:3000
Connection: keep-alive
Content-Length: 54
Content-Type: application/json; charset=utf-8
Date: Fri, 25 Nov 2016 07:23:36 GMT

此时,完成了这个用户接口的请求。

让我们回顾一下,首先我们使用了一个非简单请求。这将会在第一次使用了一个OPTIONS请求,来探明服务器是否接收后续真正的请求。OPTIONS请求是一个HTTP/1.1的方法,不包含请求数据,理论上不会对服务器造成影响。随同一起发送了:

Access-Control-Request-Headers: content-type, x-requested-with
Access-Control-Request-Method: POST

请求头Access-Control-Request-Headers来提醒服务器使用POST方法。而请求头Access-Control-Request-Method则告知服务器将使用这两种自定义头或数据为自定义。这样服务器就可以决定,在此条件下是否继续接受跨站访问。

而此时返回了:

Access-Control-Allow-Headers: content-type, x-requested-with
Access-Control-Allow-Origin: http://localhost:3000

第一行Access-Control-Allow-Headers表示该服务接受:content-type, x-requested-with这两种自定头或内容自定。Access-Control-Allow-Origin表明,服务器只接受来自http://localhost:3000的请求。

有趣的是,第二次发出的POST并不包含Access-Control-Request等标记。为了实现查询缓存,我们还可以使用Access-Control-Request-Headers来告诉服务端,在该时间段内无需再次验证。不过有同学反馈,该值最大为5分钟。

我们如果抓包来看,会看到两个请求发出:


带有OPTIONS的请求示例

此时,完成了整个非简单请求:预请求+请求本体

Credentials

在使用XMLHttpRequestfetch发请求时,会附带一些额外信息,通常是Cookies或验证信息。默认情况下是不会发送的,当你使用一个特殊的属性withCredentials可以控制是否发送。

例如我刚刚已经登录成功,下面要获取用户信息,这个请求会包含cookie信息:

fetch('http://localhost:3001/user', {
method: 'GET',
headers: {
  credentials: 'include'
})

这是一个简单请求,因此不会发出预请求,如果服务器响应并返回:
Access-Control-Allow-Credentials:true
则表明支持附加Cookie请求。

追问

预请求是原子的吗

因为非简单请求会先发起预请求,然后后再发出请求本体。那么,如果我在服务端探测到OPTIONS请求会返回允许的Access-Control-Allow信息,然后在第二次请求发起前修改为不允许跨域,客户端是否会如何处理?


请求逻辑

实际上,这个问题很好的解释了为什么会有预请求。预请求的设计目的是在不对服务器产生任何影响下查询跨域信息。所以测试结果和设想的一样:

第二次请求返回拒绝跨域时,拦截请求内容,浏览器抛出跨域错误。但实际服务端已经执行了这段代码,产生了影响——即便是跨域的。

为什么简单请求不需要预请求

我们来看下简单请求的定义,即为GET HEAD POST方法。
通常我们语义化的GET请求不会包含执行查询,因此理论上不需要发出不执行查询的预请求。

资源请求真的是不会跨域吗

如果我在资源引用类请求上加上访问控制头会怎样?
我在针对不同的资源请求上加上了跨域限制,结果出乎意料,大部分会无视这一限制:

  • <script src="http://localhost:3001/a.js">,没事
  • ![](http://localhost:3001/a.png) 没事
  • <link href="http://localhost:3001/a.css" rel="stylesheet"> 没事

但字体加载却抛出了跨域错误:(Chrome、Safari下测试)

  • @font-face {font-familu:'test' src: url('http://localhost:3001/a.woff'}

随后我查询到,浏览器确实在对字体引入是加入访问控制的,因为字体文件是有版权限制,这一特性可以让字体发行商通过跨域限制来授权给特定网站。

其他

关于如何解决跨域,除了加入CORS头之外,实在是有太多的方案了,在此不做累述。不过,我们可以总结出一句话:
“由非XMLHttpRequestfetch发起的资源请求,且不是@font-face的,都可以用来做跨域解决办法”

  • 例如我们常见的JSONP方案。

相关链接

日记本
Web note ad 1