HTML5的CORS

Aug 29, 2016


前言

今天在使用leap框架的时候遇到了一个CORS的问题,因为leap其实是支持CORS的,但是由于leap的bug导致请求一直不成功,花了点时间调试,同时也重新认识了CORS,本来今天准备写写关于SSO技术的博客,既然遇到这个问题,那就先把这个问题拿出来讨论一下吧。

什么是CORS

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

其实早在HTML5之前,我们就已经开始了对跨域请求的追求,早前的解决方案一般有几个:

  • cookie跨域
  • iframe + location.hash
  • window.name属性
  • jsonp

实际上上面这几种策略,大部分都很受限制,cookie要求不同的域必须在同一个根域下,iframe+location.hash的方式由于受url现在并不能传递太多信息,window.name需要做跳转,实际处理起来比较麻烦,jsonp大概是最简单的了,但是却没办法做错误处理,而且代码调试也非常麻烦。

在html5以后,跨域请求的问题才算是真正解决了,采用的就是html5中规定的CORS标准,唯一的问题大概就是这种请求无法支持低版本的浏览器了。不过随着时代发展,低版本的浏览器最终还是会消亡的。

CORS标准

两种请求

在CORS标准中,把跨域的请求分成了两种:

  • 简单请求
  • 非简单请求

无论是简单请求还是非简单请求,支持CORS的浏览器都会按照CORS标准在请求头中附加信息给后端,然后验证后端响应的请求头信息,最终决定是否允许这次跨域请求。

接下来我们来看这两种请求的过程以及最终如何确定跨域请求是否有效和安全。

简单请求

简单请求,顾名思义,就是比较简单的请求,判断是否简单请求的标准就是同时满足如下两个条件:

  1. 请求方法是GET,POST,HEAD其中之一
  2. HTTP的头信息不超出以下几种字段:
    Accept
    Accept-Language
    Content-Language
    Last-Event-ID
    Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain
    

对于简单的请求,CORS规定浏览器直接发送请求即可,但是要在请求中添加Origin请求头。

这个Origin请求头实际上是用于给后端服务器校验是否允许该请求跨域访问的。值得注意的是,这个请求头是浏览器自动添加的,不需要开发人员在请求中显式指定,并且请求头的值就是当前应用的域名。比如如下请求:

var url = 'http://www.cors-example.com';
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.send();

我们可以看到请求真正发出去的结果如下:

GET / HTTP/1.1
Origin: http://www.kael.com:8080/
Host: www.cors-example.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

这里的www.kael.com是我本地的域名,这个域名会被浏览器自动加到Origin请求头中。

如果服务端支持跨域请求,此时返回的响应如下:

Access-Control-Allow-Origin: http://www.kael.com:8080
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: corsValue
corsValue:true
Content-Type: text/html; charset=utf-8

这里只是示意的响应,足够我们说明问题即可。

我们可以看到,这里有三个以Access-Control-开头的响应头,这三个响应头都是CORS的响应头,由浏览器负责解析处理,开发者在js代码中实际上是处理不了的。

  • Access-Control-Allow-Origin

这个请求头是必须的,表示后端允许哪个域名下的js跨域请求,一般来说都是请求的Origin请求头的值,如果是允许任意的域名跨域请求,这个值是*

当浏览器收到CORS请求的响应之后,会先根据Access-Control-Allow-Origin响应头判断当前域名是否被服务端允许跨域访问,如果发现不允许(响应头的域名不是当前域名也不是*号)的话,浏览器就会禁止js读取响应并且抛出异常。

  • Access-Control-Allow-Credentials

这个请求头是可选的,它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

这里需要注意,即使服务端已经明确Cookie可以跨域共享了,浏览器也并不是一定会在CORS中带上Cookie,这个不同浏览器有不同实现,有得默认带上,有的默认不带上,因此最好在js代码中明确指定本次请求带上Cookie:

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

如果需要明确不带上Cookie,将withCredentials设为false即可。

另外,由于Cookie是带有域名属性的,不能在非Cookie的域中使用,因此在CORS请求需要带上Cookie的时候,Access-Control-Allow-Origin请求头不能为*,因为*没有固定域名,浏览器并不知道需要带上哪些Cookie给服务端,这种情况下浏览器出于安全考虑一般不会带上Cookie。

  • Access-Control-Expose-Headers

这个请求头也是可选的,它表示的是运行js从响应头中获取哪些额外的响应头,默认情况下,通过CORS请求得到的响应,使用XMLHttpRequest对象的getResponseHeader()只能得到6个默认的响应头:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想获取其他响应头,只能通过getResponseHeader('{headername}')的方式获取,这种方式会由浏览器限制哪些头能够获取到,浏览器判断能否获取的标准就是Access-Control-Expose-Headers内是否包含要获取的请求头,因此如果服务端明确自己会在响应中加入某些特殊的响应头时,一定要在Access-Control-Expose-Headers中指定,客户端才能获取到。

非简单请求

非简单请求,简单的说就是不是简单请求的请求都是非简单请求,虽然有点拗口,不过应该很好理解。

相比于简单请求,非简单请求的CORS请求稍麻烦一些,不过不用担心,这个麻烦是浏览器要解决的问题,对于我们开发者来说,依然是透明的。

当我们的js代码发起一个非简单的CORS请求时,浏览器会判断出来,并且在这个非简单请求发出之前,给相同的URL先发一个OPTION请求,这个请求我们称为预检请求(preflight request)。

这里假设我们的js代码如下:

var url = 'http://www.cors-example.com';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-CORS-Header', 'cors');
xhr.send();

这个过程实际上浏览器并不是直接发出这个请求,而是先发一个预检请求,预检请求的请求方法是OPTIONS,并且URL和这个请求完全一致,请求头如下:

OPTIONS / HTTP/1.1
Origin: http://www.kael.com:8080/
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-CORS-Header
Host: www.cors-example.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

这里我们看到,跟CORS有关的两个请求头Access-Control-Request-MethodAccess-Control-Request-Headers, 这两个请求头分别告诉后端要发起的跨域请求的请求方法是PUT,并且附带了额外的请求头X-CORS-Header,还有一个作用跟简单请求一样的请求头Origin,这个请求其实是浏览器预先联系服务端,提醒服务端本地想要发起一个跨域请求,这个请求的基本信息都带在请求头了,由服务端自己获取这些请求并检验决定是否允许这个请求发起,如果服务端允许的话,可以返回如下的响应头告诉浏览器:

Access-Control-Allow-Origin: http://www.kael.com:8080
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-CORS-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000

当浏览器收到服务端对预检请求的响应之后,首先检查上面的三个跟CORS有关的请求头:

  • Access-Control-Allow-Origin

允许跨域请求的域名,这个作用跟简单请求的响应是一样的,同时这个响应头也是必须的。

  • Access-Control-Allow-Methods

这个响应头是必须的,表示服务端允许哪些类型的请求跨域访问,这里的例子表示后端允许PUTPOSTGET三种,这个响应头需要一次性返回所有允许的请求方法,通过逗号分割,并不是只返回本次预检请求中指定的方法。

当浏览器检查上面的三个响应头之后,会将响应头的结果和js真正要发起的请求做对比,当且仅当上面两个条件都对比通过的时候,浏览器才真正发起这个非简单请求,其他情况都会抛出异常,因此如果后端不允许跨域,可以不响应OPTIONS请求,也可以正常响应请求但是不返回上面的两个响应头。

除了两个必须的响应头之外,还有以下几个可选的响应头:

  • Access-Control-Allow-Headers

这个响应头表示服务端允许哪些额外的请求头发起跨域请求,只有当CORS预检请求中包含Access-Control-Request-Headers请求头时,这个响应头才是必需的,并且浏览器也会检查这个响应头决定真正的CORS请求是否可以发起。这个响应头表示的是服务端允许的所有额外响应头,并不是当次请求的额外响应头,通过逗号分割。

  • Access-Control-Allow-Credentials

这个响应头的作用和简单请求的完全一致。

  • Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

当预检请求通过浏览器和服务端两边的校验之后,浏览器就会真正发起js代码中的CORS请求并把响应结果交给js代码处理了。

CORS的安全性

在前面CORS的标准中我们可以看到,无论是简单请求还是非简单请求,一次跨域请求其实会同时经过浏览器和服务端两边的配合校验通过才行,因此这个请求其实是安全的,只有服务端授权过的域名和请求类型才允许跨域请求,因此CORS是安全的跨域请求。

同时我们也知道,CORS是需要服务端支持的,也就是说,如果服务端没有特殊的对CORS进行支持,浏览器还是无法发起跨域请求,所以CORS虽然很好的解决了跨域资源共享中的安全性问题,但是同时对客户端和服务端都提出了要求,这就决定了旧版本的浏览器和服务端不能支持,对于无法支持CORS的浏览器和服务端,我们还是只能用JSONP的方式解决跨域问题。

关于服务端如何支持CORS的内容,我准备在leap的笔记中写一篇博客,具体说说leap框架对CORS的支持,到时候再一起分享吧。