二狗子翻车了,只因上了这个网站……

今天故事的主角还是大家熟识的二狗子。二狗子拿到了一笔项目奖金,在好好犒劳了自己一顿后,决定把剩下的钱在银行存个定期。

他用浏览器访问了 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 属于同源?

  • https://www.bank.com/save.html (✅)

  • http://www.bank.com/withdraw.html (❌,协议不同)

  • https://bank.com/login.html (❌,域名不同)

  • https://www.bank.com:8080/withdraw.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 秒),在此期间,不用再发出另一条预检请求。

你可能感兴趣的:(二狗子翻车了,只因上了这个网站……)