今天故事的主角还是大家熟识的二狗子。二狗子拿到了一笔项目奖金,在好好犒劳了自己一顿后,决定把剩下的钱在银行存个定期。
他用浏览器访问了 www.bank.com,输入了用户名和密码后,成功登录。
bank.com 返回了 cookie 用来标识二狗子这个用户。
不得不说,浏览器是个认真负责的工具,它会把这个 cookie 记录下来,以后二狗子每次向 bank.com 发起 HTTP 请求,浏览器都会准确无误地把 cookie 加入到 HTTP 请求头部中,一起发送到 bank.com,这样 bank.com 就知道二狗子已经登陆过了,就可以按照二狗子的请求来做事情,比如查看余额、转账取钱。
二狗子存完钱,看着账户余额,心中暗喜。于是,他打开了 www.meinv.com,去看自己喜欢的电影。
但二狗子不知道的是,浏览器把 meinv.com 的 HTML、JavaScript 都下载到本地,开始执行。而其中某个 JavaScript 中,偷偷创建了一个 XMLHttpRequest 对象,然后向 bank.com 发起了 HTTP 请求 。
浏览器严格按照规定,把之前存储的 cookie 也添加到 HTTP 请求中。但是 bank.com 根本不知道这个 HTTP 请求是 meinv.com 的 JavaScript 发出的,还以为是二狗子发出的。bank.com 检查了cookie,发现这是一个登录过的用户,于是兢兢业业地去执行请求命令,二狗子的个人信息就泄露了。(ps. 实际中实施这样一次攻击不会这么简单,银行网站肯定是做了其他很多安全校验的措施,本故事只是用来说明基本原理。)
可怜的二狗子还不知道发生了什么,已经遭受了钱财损失。那我们来帮他复盘一下为什么会发生这种情况。
首先,每当访问 bank.com 的时候,不管是人点击按钮访问链接,还是通过程序的方式,存储在浏览器的 bank.com 的 cookie 都会进行传递。
其次,从 meinv.com 下载的 JavaScript 利用 XMLHttp 访问了 bank.com。
第一点我们是无法阻止的,如果阻止了,cookie 就丧失了它的主要作用。
对于第二点,浏览器必须做出限制,不能让来自 meinv.com 的 JavaScript 去访问 bank.com。这个限制就是同源策略。
同源策略
浏览器提供了 fetch API 或 XMLHttpRequest 等方式,它们可以使我们方便快捷地向后端发起请求,取得资源,展示在前端上。而通过 fetch API 或 XMLHttpRequest 等方式发起的 HTTP 请求,就必须要遵守同源策略 。
那什么是同源策略呢?同源策略(same-origin policy)规定了当浏览器使用 JavaScript 发起 HTTP 请求时,如果是请求域名同源的情況下,请求不会受到限制。但如果是非同源的请求,则会强制遵守 CORS (Cross-Origin Resource Sharing,跨源资源共享) 的规范,否则浏览器就会将请求拦截。
那什么情况下是同源呢?同源策略非常严格,要求两个 URL 必须满足下面三个条件才算同源:
1、协议(http/https)相同;
2、域名(domain)相同;
3、端口(port)相同。
举个例子:下列哪些 URL 地址与 https://www.bank.com/withdraw.html 属于同源?
http://www.bank.com/withdraw.html (❌,协议不同)
https://bank.com/login.html (❌,域名不同)
因此,当我们请求不同源的 URL 地址时,就会产生一个跨域 HTTP 请求(cross-origin http request)。
例如想要在 https://www.upyun.com 的页面上显示来自 https://opentalk.upyun.com 的资讯内容,我们使用浏览器提供的 fetch API 来发起一个请求:
···
try {
fetch('https://opentalk.upyun.com/data')
} catch (err) {
console.error(err);
}
···
这就产生了一个跨域请求,跨域请求则必须遵守 CORS 的规范。
当请求的服务器没有配置允许 CORS 访问或者不允许来源地址的话,请求就会失败,在 Chrome 的开发者工具台上就会看到以下的经典错误:
···
Access to fetch at 'https://opentalk.upyun.com/data' from origin 'https://www.upyun.com' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
···
那在实际应用中,我们该如何正确地设定 CORS 呢?
什么是 CORS
CORS 是针对不同源(域)的请求而制定的规范。浏览器在请求不同域的资源时,被跨域请求的服务端必须明确地告知浏览器其允许何种请求。只有在服务器允许范围内的请求才能够被浏览器放行并请求,否则会被浏览器拦截,访问失败。
在 CORS 规范中,跨域请求主要分为两种:简单请求(simple request)和非简单请求(not-so-simple request)。
简单请求
简单请求必须符合以下四个条件,实际开发中我们一般只关注前面两个条件:
(1)使用 GET、POST、HEAD 其中一种方法;
(2)只使用了如下的安全请求头部,不得人为设置其他请求头部:
Accept
Accept-Language
Content-Language
Content-Type 仅限以下三种:
text/plain
multipart/form-data
application/x-www-form-urlencoded
(3)请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器,XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问;
(4)请求中没有使用 ReadableStream 对象。
不符合以上任一条件的请求就是非简单请求。浏览器对于简单请求和非简单请求,处理的方式也不一样。
对于简单请求,浏览器会直接发出 CORS 请求。具体来说,就是在请求头信息中,自动地增加一个Origin (来源)字段。
Origin 的值中,包含请求协议、域名和端口三个部分,用于说明本次请求来自哪个源。服务器可以根据这个值,决定是否同意这次请求。例如下面的请求头报文:
···
GET /data HTTP/2
Host: opentalk.upyun.com
accept-encoding: deflate, gzip
accept: /
origin: https://www.upyun.com
......
···
如果 Origin 指定的源不在服务器允许范围内,服务器会返回响应一个正常的 HTTP,浏览器发现回应头部中,如果没有包含 Access-Control-Allow-Origin 字段,就会抛出错误。需要注意的是,这种错误无法通过状态码识别,HTTP 响应的状态码有可能是 200。
如果 Origin 指定的源在允许范围内的话,响应头部中,就会有以下几个字段:
···
Access-Control-Allow-Origin: https://www.upyun.com
Access-Control-Allow-Headers: Authorization
Access-Control-Expose-Headers: X-Date
Access-Control-Allow-Credentials: true
···
大家可能也看出来了一个特点,与 CORS 请求相关的字段,都以 Access-Control- 开头。
如果跨域请求是被允许的,那么响应头部中是必须有 Access-Control-Allow-Origin 头部的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意域名的请求。
Access-Control-Allow-Credentials 是一个可选字段,它的值是一个布尔值,表示是否允许发送Cookie。如果发起跨域请求时,设置了 withCredentials 标志为 true,浏览器在发起跨域请求时,也会同时向服务器发送 cookie。如果服务器端的响应中不存在 Access-Control-Allow-Credentials 头部,浏览器就不会响应内容。
特别需要说明的是,如果请求端设置了 withCredentials ,Access-Control-Allow-Origin 的值就必须是具体的域名值,而不能设置为 *,否则浏览器也会抛出跨域错误。
Access-Control-Expose-Headers 也是一个可选头部。当进行跨域请求时,XMLHttpRequest 对象的 getResponseHeader()方法只能拿到 6 个基本响应字段:
Cache-Control
Content-Language
Content-Type
Expires
Last-Modified
Pragma
而如果开发者需要获取其他响应头部字段,或者一些自定义响应头部,服务器就可以通过设置 Access-Control-Expose-Headers 头部来指定发起端可访问的响应头部。
非简单请求
非简单请求往往是对服务器有特殊要求的请求,比如请求方法为 PUT 或 DELETE,或者 Content-Type 字段类型是 application/json。
对于非简单请求的 CORS 请求,浏览器会在正式发起跨域请求之前,增加一次 HTTP 查询请求,我们称为预检请求(preflight)。浏览器会先询问服务器,当前的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 请求方法和请求头部字段。只有得到肯定答复,浏览器才会发出正式的跨域请求,否则就会报错。
比方说我们使用代码发起一个跨域请求:
···
fetch('http://opentalk.upyun.com/data/', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CUSTOM-HEADER': '123'
}
})
···
浏览器会发现这是一个非简单请求,它会自动发送一个 OPTIONS 的预检请求,其中核心内容有两部分,Access-Control-Request-Method 表示后面的跨域请求需要用到的方法,Access-Control-Request-Headers 表示后面的跨域请求头内会有该内容。
···
OPTIONS /data/ HTTP/1.1
Host: opentalk.upyun.com
Origin: http://www.upyun.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-MY-CUSTOM-HEADER, Content-Type
···
服务器收到预检请求后,检查这些特殊的请求方法和头自己能否接受,如果接受,会在响应头部中包含如下信息:
···
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
Access-Control-Max-Age: 86400
Access-Control-Allow-Headers: X-Date, range, X-Custom-Header, Content-Type
Access-Control-Expose-Headers: X-Date, X-File, Content-type
......
···
上面的 HTTP 响应中,关键的是 Access-Control-Allow-Origin 字段,* 表示同意任意跨源请求都可以请求数据。部分字段我们在简单请求中解释过了,这里挑几个需要注意的头部解释一下。
Access-Control-Allow-Methods,这是个不可缺少的字段,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。
Access-Control-Allow-Headers 字段为一个逗号分隔的字符串,表明服务器支持的所有请求头部信息字段,不限于浏览器在预检中请求的字段。
Access-Control-Max-Age:该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是 1 天(86400 秒),在此期间,不用再发出另一条预检请求。