浏览器同源策略

同源:如果两个页面的协议,端口和域名都相同,则两个页面同源

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互,这是一个用于隔离潜在恶意文件的重要安全机制

跨域方法

jsonp

以淘宝网搜索框为例,输入电脑可以看到页面向后端发送了 get 请求,其 url 为

1
https://suggest.taobao.com/sug?code=utf-8&q=%E7%94%B5%E8%84%91&_ksTS=1572254071001_546&callback=jsonp547&k=1&area=c2c&bucketid=16

其中有参数为callback=jsonp547,在浏览器输入该 url,返回

1
jsonp547({"result":[["电脑椅","112957.25494307223"],["电脑包","185667.87352325322"],["电脑台式桌","53369.62559995722"],["电脑主机","127618.426922485"],["电脑笔记本","192247.33896850277"],["电脑包女双肩","225481.05965832053"],["电脑音响","31156.14154566319"],["电脑椅家用","62166.67675737684"],["电脑家用桌","190343.38565178483"],["电脑包15.6寸","16574.673889620495"]],"magic":[{"index":"2","type":"tag_group","data":[[{"title":"17寸"},{"title":"15寸"},{"title":"14寸","type":"hot"}],[{"title":"Asus\/华硕","type":"hot"},{"title":"Dell\/戴尔"}],[{"title":"双肩","type":"hot"},{"title":"单肩包"},{"title":"女士"},{"title":"笔记本"},{"title":"商务"}]]},{"index":"5","type":"tag_group","data":[[{"title":"苹果"},{"title":"三星"},{"title":"四核"},{"title":"戴尔"},{"title":"游戏本"},{"title":"独显"},{"title":"分期","type":"hot"},{"title":"14英寸"},{"title":"酷睿"},{"title":"华硕"}]]},{"index":"7","type":"tag_group","data":[[{"title":"USB供电","type":"hot"},{"title":"usb供电"}],[{"title":"Hivi\/惠威"},{"title":"炫目"},{"title":"低音炮"},{"title":"播放器","type":"hot"},{"title":"组合"},{"title":"漫步者"},{"title":"台式"},{"title":"笔记本"}]]}]})

可以看到,返回的值是一个名为jsonp547的函数,其参数为我们需要获取的数据,如果将callback的名字改成a,则返回

1
a({"result":[["电脑椅","112957.25494307223"],["电脑包","185667.87352325322"],["电脑台式桌","53369.62559995722"],["电脑主机","127618.426922485"],["电脑笔记本","192247.33896850277"],["电脑包女双肩","225481.05965832053"],["电脑音响","31156.14154566319"],["电脑椅家用","62166.67675737684"],["电脑家用桌","190343.38565178483"],["电脑包15.6寸","16574.673889620495"]],"magic":[{"index":"2","type":"tag_group","data":[[{"title":"17寸"},{"title":"15寸"},{"title":"14寸","type":"hot"}],[{"title":"Asus\/华硕","type":"hot"},{"title":"Dell\/戴尔"}],[{"title":"双肩","type":"hot"},{"title":"单肩包"},{"title":"女士"},{"title":"笔记本"},{"title":"商务"}]]},{"index":"5","type":"tag_group","data":[[{"title":"苹果"},{"title":"三星"},{"title":"四核"},{"title":"戴尔"},{"title":"游戏本"},{"title":"独显"},{"title":"分期","type":"hot"},{"title":"14英寸"},{"title":"酷睿"},{"title":"华硕"}]]},{"index":"7","type":"tag_group","data":[[{"title":"USB供电","type":"hot"},{"title":"usb供电"}],[{"title":"Hivi\/惠威"},{"title":"炫目"},{"title":"低音炮"},{"title":"播放器","type":"hot"},{"title":"组合"},{"title":"漫步者"},{"title":"台式"},{"title":"笔记本"}]]}]})

可见,返回的函数名与我们传入的参数值有关,根据这个,我们想要跨域,可以通过创建一个<script>标签,并将其 src 设置为对应的 url 来获取数据,然后在 html 中运行返回的函数

index.html
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
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
window[callback] = function (data) {
resolve(data)
document.body.removeChild(script)
}
// 配置参数为 xx=xx&xx=xx的形式
params = { ...params, callback }
let arr = []
for (let key in params) {
arr.push(`${key}=${params[key]}`)
}
let script = document.createElement('script')
script.src = `${url}?${arr.join('&')}`
document.body.appendChild(script)
})
}

jsonp({
url: 'http://localhost:3000/show',
params: {
keyword: 'I love you'
},
callback: 'say'
}).then(data => {
console.log(data)
})

服务端的代码为

server.js
1
2
3
4
5
6
7
8
9
10
11
const express = require('express')

let app = express()

app.get('/show', (req, res) => {
let { keyword, callback } = req.query
console.log(keyword)
res.end(`${callback}('I love you too')`)
})

app.listen(3000)

运行后可以看到

命令行
1
I love you
浏览器
1
I love you too

jsonp 的缺点是只能发送 get 请求,而且不安全,易导致 xss 攻击

CORS

假设有这样一种情况,有一个页面 index.html,要通过 http://localhost:3000 来访问它,对应的后端代码可以设置为

server1.js
1
2
3
4
5
6
7
const express = require('express')

let app = express()

app.use(express.static(__dirname))

app.listen(3000)

在页面中如果想获取数据,可以设置 ajax

1
2
3
4
5
6
7
8
9
10
11
12
<script>
let xhr = new XMLHttpRequest()
xhr.open('get', 'http://localhost:5000/say', true)
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log(xhr.response)
}
}
}
xhr.send()
</script>

然而如果想获取的数据来自其他域( http://localhost:5000/say )下,则会报错

浏览器
1
2
Access to XMLHttpRequest at 'http://localhost:5000/say' from origin 'http://localhost:3000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

很明显,由于端口号不同,该请求被浏览器同源策略所阻止,但是这个请求是被浏览器所屏蔽,接受端服务器依然可以收到请求,另一个服务器代码如下

server2.js
1
2
3
4
5
6
7
8
9
10
11
12
const express = require('express')

let app = express()

app.get('/say', (req, res) => {
console.log(req.headers)
res.end('I love you')
})

app.use(express.static(__dirname))

app.listen(5000)

可以看到,server2 的命令行输出了请求的头

server2命令行
1
2
3
4
5
6
7
8
9
10
11
{ host: 'localhost:5000',
connection: 'keep-alive',
origin: 'http://localhost:3000',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36',
accept: '*/*',
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
referer: 'http://localhost:3000/',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' }

可以看到,其中有一个 header 描述了当前访问 server2 的源

1
origin: 'http://localhost:3000'

可以设置一个“白名单”,将允许访问的源添加进去,并添加响应头Access-Control-Allow-Origin

server2.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// code ...
let whiteList = ['http://localhost:3000']

app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
next()
})

app.get('/say', (req, res) => {
console.log(req.headers)
res.end('I love you')
})
// code ...

设置完后在访问页面,可以看到数据成功获取

浏览器
1
I love you

如果在页面中设置一个请求头

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
let xhr = new XMLHttpRequest()
xhr.open('get', 'http://localhost:5000/say', true)
xhr.setRequestHeader('name', 'tfcx')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log(xhr.response)
}
}
}
xhr.send()
</script>

发现浏览器报错

浏览器
1
2
Access to XMLHttpRequest at 'http://localhost:5000/say' from origin 'http://localhost:3000' has been blocked by CORS policy:
Request header field name is not allowed by Access-Control-Allow-Headers in preflight response.

显然,需要添加响应头

server2.js
1
2
3
4
5
6
7
8
app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'name')
}
next()
})

重启之后可以看到浏览器正常接收数据,server2 的命令行显示

server2
1
2
3
4
5
6
7
8
9
10
11
12
{ host: 'localhost:5000',
connection: 'keep-alive',
origin: 'http://localhost:3000',
name: 'tfcx',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36',
accept: '*/*',
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
referer: 'http://localhost:3000/index.html',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7' }

其中有

1
name: 'tfcx'

如果需要通过 ajax 发送其他类型的请求,比如 put

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
<script>
let xhr = new XMLHttpRequest()
xhr.open('put', 'http://localhost:5000/say', true)
xhr.setRequestHeader('name', 'tfcx')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log(xhr.response)
}
}
}
xhr.send()
</script>

浏览器显示

浏览器
1
2
Access to XMLHttpRequest at 'http://localhost:5000/say' from origin 'http://localhost:3000' has been blocked by CORS policy:
Method PUT is not allowed by Access-Control-Allow-Methods in preflight response.

在服务端添加响应头Access-Control-Allow-Methods

server2.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'name')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
}
next()
})

app.put('/say', (req, res) => {
console.log(req.headers)
res.end('I love you')
})

重启之后可以看到浏览器正常接收数据,打开浏览器控制台可以看到,浏览器在发送 PUT 请求之前先发送了一个 OPTIONS 请求,该请求称为预检请求,用于判断是否能跨域,可以设置对 OPTIONS 请求不做处理;同时可以看到,以不同的时间刷新页面时,有时会发送预检请求,而有时则不会,可已设置一个最大时间

server2.js
1
2
3
4
5
6
7
8
9
10
11
12
13
app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'name')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
res.setHeader('Access-Control-Max-Age', 6000)
if (req.method === 'OPTIONS') {
res.end()
}
}
next()
})

如果设置了 cookie

index.html
1
2
3
4
<script>
document.cookie = 'name=th'
// ajax ...
</script>

由于 cookie 不能跨域,打开页面会发现 server2 并没有收到 cookie,可以在页面中设置withCredentials为 true

index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
document.cookie = 'name=th'
let xhr = new XMLHttpRequest()
xhr.withCredentials = true
xhr.open('put', 'http://localhost:5000/say', true)
xhr.setRequestHeader('name', 'tfcx')
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
console.log(xhr.response)
}
}
}
xhr.send()
</script>

此时刷新页面可以看到浏览器报错

浏览器
1
2
3
Access to XMLHttpRequest at 'http://localhost:5000/say' from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request does not pass access control check:
The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

可以看出还需要配置一个头

server2.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Headers', 'name')
res.setHeader('Access-Control-Allow-Methods', 'PUT')
res.setHeader('Access-Control-Allow-Credentials', true)
// 设置最长时间间隔
res.setHeader('Access-Control-Max-Age', 6000)
if (req.method === 'OPTIONS') {
res.end()
}
}
next()
})

重启服务器后可以看到,cookie 被正常发送

server2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{ host: 'localhost:5000',
connection: 'keep-alive',
'content-length': '0',
origin: 'http://localhost:3000',
name: 'tfcx',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.70 Safari/537.36',
accept: '*/*',
'sec-fetch-site': 'same-site',
'sec-fetch-mode': 'cors',
referer: 'http://localhost:3000/',
'accept-encoding': 'gzip, deflate, br',
'accept-language': 'en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7',
cookie: 'name=th' }

如果想设置响应的头

server2.js
1
2
3
4
app.put('/say', (req, res) => {
res.setHeader('location', 'Xi\'an')
res.end('I love you')
})

在页面中输出这个头的值

index.html
1
2
3
4
5
<script>
// code ...
console.log(xhr.getResponseHeader('location'))
// code ...
</script>

浏览器再次报错,并且输出的结果为 null

浏览器
1
Refused to get unsafe header "location"

但是在浏览器的调试界面可以看到,这个响应头是被成功发送的,还需要进一步的配置使浏览器认为这个头是安全的

server2.js
1
2
3
4
5
6
7
8
app.use((req, res, next) => {
let origin = req.headers.origin
if (whiteList.includes(origin)) {
// code ...
res.setHeader('Access-Control-Expose-Headers', 'location')
}
next()
})

postMessage

一个窗口可以获得对另一个窗口的引用,然后在窗口上调用targetWindow.postMessage()方法分发一个 MessageEvent 消息,接收消息的窗口可以根据需要自由处理此事件

传递给window.postMessage()的参数将通过消息事件对象暴露给接收消息的窗口

假设有两个页面 a.html 和 b.html,其对应的端口分别问 3000 和 5000

a.html 中包含一个 iframe ,引用 http://localhost:5000/b.html ,向 b.html 发送数据,同时接收其发回的数据

a.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<body>
<iframe src="http://localhost:5000/b.html"
frameborder="0"
id="frame"
onload="load()"></iframe>
<script>
function load() {
const frame = document.getElementById('frame')
frame.contentWindow.postMessage('I love you', "http://localhost:5000")
window.onmessage = function (e) {
console.log(e.data)
}
}
</script>
</body>
b.html
1
2
3
4
5
6
7
8
<body>
<script>
window.onmessage = function (e) {
console.log(e.data)
e.source.postMessage('I love you too', e.origin)
}
</script>
</body>
http://localhost:3000
1
2
I love you                        // => b.html
I love you too // => a.html

window.name

window.name 是一个全局属性,只要在一个 window 下,无论 url 怎么变化,window.name 都不会改变

在 iframe 中,即使 src 在变化,iframe 中的 window.name 也是一个固定的值

location.hash