同源策略与跨域方案

有时候浏览器的 ajax 请求不能正常拉取到别的站点的数据,这是为什么呢?


同源策略

为了阻止一些低成本的跨站攻击,浏览器采取了同源策略

同源策略要求 3 个标准都要完全一致

  1. 协议
  2. 域名
  3. 端口

三个只要有任何一个不一致,那就认为不是同源,这时候的请求虽然能成功发到目标服务器,但并不能取得数据,因为数据被浏览器扣留了

举几个例子(http 默认地址是 80 端口,https 是 443,此时写与不写并无区别)

站点 A 站点 B 是否同源
http://ringoer.com/ https://ringoer.com/ 否,协议不同
https://www.ringoer.com/ https://ringoer.com/ 否,域名不同
https://ringoer.com:12450/ https://ringoer.com/ 否,端口不同
https://ringoer.com/archives/ https://ringoer.com/tags/index.html

同源又称之为”同一个域”(不是域名)

跨域

但有的时候不得不跨站请求数据,比如前后端分离的时候,服务器地址往往和前端地址不一样

为了解决同源策略带来的限制,提出了跨域方案

常用的跨域方案有 JSONP 跨域和 CORS 跨域,还有一些其它的基于 iframe 的跨域,此处不介绍

JSONP 跨域

JSONP 跨域是在 IE 上的一种妥协

主要原理是利用 js 脚本可以任意引用,从而在 A 站与 B 站达成协议的情况下,B 站直接在 js 脚本里包藏数据,A 站直接引用这个脚本

但是 A 站引用后也不能直接看到脚本内容,所以通常采用的方式是 A 站预定义一个数据处理方法,B 站在脚本中直接调用该方法并传入数据

同时一般为了保持隐秘性,会让每次的数据处理方法名都不一样

一个 JSONP 实例如下

首先准备两台服务器模拟 A 站和 B 站

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var express = require('express');
var fs = require('fs')
var ringoer = express();

ringoer.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});

var serverringoer = ringoer.listen(9999, () => {

var host = serverringoer.address().address;
var port = serverringoer.address().port;

console.log("启动于 http://%s:%s", host, port);

})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var express = require('express');
var fs = require('fs')

var app = express();

app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
app.get('/friends.json', (req, res) => {
res.sendFile(__dirname + '/friends.json');
});
app.get('/friends.js', (req, res) => {
let str = fs.readFileSync('friends.js').toString()
let json = fs.readFileSync('friends.json').toString()
let random = req.query.callback
str = str.replace('{{callback}}', random.toString())
.replace(`'{{data}}'`, json)
console.log(str)
res.send(str);
});
app.get('/static/*', (req, res) => {
res.sendFile(__dirname + req.path);
});

var server = app.listen(8888, () => {

var host = server.address().address;
var port = server.address().port;

console.log("启动于 http://%s:%s", host, port);

})

然后准备一个 html 用于显示 A 站的页面,请求 B 站的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Hello World</title>
</head>

<body>
<h1>Hello World</h1>
<button id="test">点我发请求</button>
<div id="data"></div>
<script>
test.onclick = () => {
let random = Math.random().toString()
let script = document.createElement('script')
script.src = 'http://qq.com:8888/friends.js?callback=' + random
window[random] = (res) => {
data.innerHTML = JSON.stringify(res)
}
document.body.appendChild(script)
script.onload = res => {
script.remove()
}
}
</script>
</body>

</html>

html 里的内部 js 定义了一个随机函数,函数的效果是将字符串塞进显示区域内

同时定义了 script 标签取回后的动作,是执行取回的 js 后(默认动作),移除自身这个标签

再准备一个 friend.js 用于给 B 站返回

1
2
3
window['{{callback}}'](
'{{data}}'
)

该代码执行一个函数,传入指定的数据

这里是保留两个占位符,用服务器取得的请求参数(B 站代码的第 15 行)和数据库里取回的数据来填充

所以还要再写一个 json 用来临时顶替数据库保存数据

1
2
3
4
5
6
7
8
9
10
[
{
"name": "ringoer",
"age": 20
},
{
"name": "enatsu",
"age": 20
}
]

先设置一下本地的 hosts 文件改变寻址,再启动两台服务器,访问 A 站的首页并点击按键发送请求,就可以看到原本不能取得的数据被打印在了 A 站首页的内容区域

CORS 跨域

CORS 跨域是利用 Access-Control-Allow-Origin 的请求头,来设置跨域允许

该请求头一般是在服务端处理请求,要回送响应的时候,设置在响应包中

该请求头的值可以是星号表示允许所有跨域,也可以是由分号分隔的域名列表

一个 CORS 实例如下

首先准备两台服务器模拟 A 站和 B 站

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var express = require('express');
var ringoer = express();

ringoer.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});

var serverringoer = ringoer.listen(9999, () => {

var host = serverringoer.address().address;
var port = serverringoer.address().port;

console.log("启动于 http://%s:%s", host, port);

})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var express = require('express');

var app = express();

app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});
app.get('/friends.json', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', 'http://enatsu.com:9999')
res.sendFile(__dirname + '/friends.json');
});
app.get('/static/*', (req, res) => {
res.sendFile(__dirname + req.path);
});

var server = app.listen(8888, () => {

var host = server.address().address;
var port = server.address().port;

console.log("启动于 http://%s:%s", host, port);

})

然后准备一个 html 用于显示 A 站的页面,请求 B 站的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Hello World</title>
<link rel="stylesheet" href="static/style.css">
</head>

<body>
<h1>Hello World</h1>
<button id="test">点我发请求</button>
<div id="data"></div>
<script>
test.onclick = () => {
let xhr = new XMLHttpRequest();
xhr.open('GET', 'http://qq.com:8888/friends.json')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4 && xhr.status === 200) {
data.innerText = xhr.response
}
}
xhr.send()
}
</script>
</body>

</html>

html 里的内部 js 定义了一个 ajax,并规定当 ajax 返回数据时,填充数据到内容区域

再准备一个 friend.json 用于给 B 站返回

1
2
3
4
5
6
7
8
9
10
[
{
"name": "ringoer",
"age": 20
},
{
"name": "enatsu",
"age": 20
}
]

先设置一下本地的 hosts 文件改变寻址,再启动两台服务器,访问 A 站的首页并点击按键发送请求,就可以看到数据成功填充,并且控制台的请求包有 ‘Access-Control-Allow-Origin’ 字段

简单请求和非简单请求

但是刚刚的 CORS 跨域只是最简单的 CORS 跨域,其实 CORS 跨域还有很多限制

浏览器将CORS请求分成两类:简单请求和非简单请求

只要同时满足以下两大条件,就属于简单请求

  1. 请求方法是以下三种方法之一

    • HEAD
    • GET
    • POST

    刚才的例子中我们采用的就是 GET 方法

  2. HTTP 请求包的头部不含有除以下字段外的其它字段

    • Accept

    • Accept-Language

    • Content-Language

    • Last-Event-ID

    • Content-Type,只能是空或以下三种之一

      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain

      可见 application/json 请求是不行的

这是为了兼容表单,因为历史上表单一直可以发出跨域请求

ajax 的跨域设计就是,只要表单可以发,ajax 就可以直接发

说是上面那么说,其实实际开发的时候谁记得住(

只要记得把 Chrome 开发者工具的 Disable cache 开了,然后看看请求有没有发个 options 预检就行了

如果后端没有配置的话,任何非简单请求都不会被放行

简单请求

对于简单请求,浏览器会在请求包中增加一个 Origin 字段,然后直接发出 CORS 请求

如果 Origin 指定的源不在许可范围内,服务器就会返回一个正常的 HTTP 响应,浏览器发现响应包中没有 Access-Control-Allow-Origin 字段,就会抛出一个错误

如果成功返回了,浏览器得到的响应可能会多出几个字段

1
2
3
4
Access-Control-Allow-Origin: http://enatsu.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Authorization
Content-Type: text/html; charset=utf-8

其中前三个都是和 CORS 高度相关的头部,且第一个和第四个是一定会出现的头部

Access-Control-Allow-Origin

如果 CORS 成功返回,则该字段只能是两种情况之一

  1. 星号,表示允许所有跨域
  2. 与请求中 Origin 字段相同

Access-Control-Allow-Credentials

浏览器在 CORS 请求中默认是不发送 Cookie 的

如果服务器允许该源的 CORS 请求发送 Cookie,将该字段设为 true 即可

该字段不能设为 false,如果想设为 false,可以直接删除该字段

Access-Control-Expose-Headers

浏览器在 CORS 请求中,响应包的默认头部一般只有以下 6 个基本字段

  1. Cache-Control
  2. Content-Language
  3. Content-Type
  4. Expires
  5. Last-Modified
  6. Pragma

但是看到 Etag 什么的也别惊讶,毕竟实际开发不能认死理对吧

如果需要让浏览器拿到其它字段,服务器必须设置该字段的值

比如上例中设置该字段的值为 Authorization,则浏览器就可以从响应头部中取得字段 Authorization 的值

Content-Type

该字段取值取决于返回的数据类型,不解释了

withCredentials

刚才说到浏览器在 CORS 请求中默认是不发送 Cookie 的,如果有这个需求,应该由服务器设置 Access-Control-Allow-Credentials 为 true

但其实这样还不够,原则上来说,不止服务端要同意,前端也要主动设置 XMLHTTPRequest 的 withCredentials 为 true,否则即使服务器同意,前端也不会发送 Cookie

但是呢,还是有的浏览器搞特殊,不设也会发,所以刚才只是说原则上是这样,2333

除了这两个要求之外,当有跨域 Cookie 需求的时候,Access-Control-Allow-Origin 的值也不能是星号,必须是具体值

非简单请求

当你请求动词是 PUT、DELETE 之类的时候,或者 Content-Type 是 application/json 的时候,就是非简单请求

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)

预检请求

浏览器会先向目标服务器发送一个 OPTIONS 请求,询问服务器当前域名是否被许可,以及允许哪些 HTTP 动词和头部字段

预检请求除了请求方法是 OPTIONS 之外,还包含三个关键字段

  1. Origin,表示请求源
  2. Access-Control-Request-Method,表示浏览器会用到哪些 HTTP 请求动词
  3. Access-Control-Request-Headers,表示浏览器会发送哪些简单请求之外的头部字段,用逗号隔开

上述前两个字段都是必须给出的

预检请求的响应

服务器确认了上述三个字段的值之后,就会做出响应

如果服务器拒绝响应,会返回一个正常的 HTTP 响应,但是没有任何头部字段,浏览器就会认为服务器拒绝了跨域预检申请,此时会触发 ajax 或 XMLHttpRequest 的 error 事件

如果服务器同意了预检请求,就会在返回的响应包中加入一些特殊的头部字段,一般有以下 4 种

  1. Access-Control-Allow-Methods,指示服务器接受哪些 HTTP 方法,必须

  2. Access-Control-Allow-Headers,指示服务器允许的头部字段

    如果请求中包含 Access-Control-Request-Headers 字段,则响应中该字段是必须的

  3. Access-Control-Allow-Credentials,指示是否允许跨域 Cookie,与简单请求中相同

  4. Access-Control-Max-Age,指示当前预检请求的有效期,单位为秒,在有效期内不需要再次发出第二次预检请求

预检请求之后

即便通过了预检,浏览器和服务器之间的交互还是存在间隙,不能像真同源一样通信

具体表现在浏览器会在每条请求中都加入 Origin 字段,服务器则会在每条响应中都加入 Access-Control-Allow-Origin 字段

总结

CORS 跨域,功能更强大,但是细节更复杂,需要开发者盯紧开发者工具,并且和后端的同学协调好(但是也便于甩锅2333

JSONP 跨域只支持 GET 方法,但是支持旧时代的浏览器(比如 IE),以及向某些达成合意但是因为某些原因不支持 CORS 的网站发送跨域请求

参考链接

跨域资源共享 CORS 详解


感谢阅读

--It's the end.Thanks for your read.--