同源策略概述
-
浏览器有一个重要的安全策略,称之为 「同源策略」;其中
源 = 协议 + 主机 + 端口
,两个源相同称之为同源,两个源不同称之为跨源或跨域,比如:源 1 源 2 是否同源 http://www.baidu.com http://www.baidu.com/news ✅ https://www.baidu.com http://www.baidu.com ❌ http://localhost:5000 http://localhost:7000 ❌ http://localhost:5000 http://127.0.0.1:5000 ❌ http://www.baidu.com http://baidu.com ❌ -
同源策略是指,若页面的源和页面运行过程中加载的源不一致时,出于安全考虑,浏览器会对跨域的资源访问进行一些限制
-
同源策略对 ajax 的跨域限制的最为 凶狠 ,默认情况下,它不允许 ajax 访问跨域资源;
-
所以通常所说的跨域问题,就是同源策略对 ajax 产生的影响,有多种方式解决跨域问题,常见的有:
- proxy 代理,常用
- CORS,常用
- JSONP
- web socket
- postMessage
- window.name + iframe
- window.location.hash + iframe
- document.domain + iframe
- nginx
跨域 - proxy 代理
开发代理
-
对于前端开发而言,大部分的跨域问题,都是通过代理解决的;
-
代理适用的场景是:生产环境不发生跨域,但开发环境发生跨域 ,因此只需要在开发环境使用代理解决跨域即可,这种代理又称之为开发代理;
-
在实际开发中,只需要对开发服务器稍加配置即可完成
// vue 的开发服务器代理配置 // vue.config.js module.exports = { devServer: { // 配置开发服务器 proxy: { // 配置代理 "/api": { // 若请求路径以 /api 开头 target: "http://dev.taobao.com", // 将其转发到 http://dev.taobao.com }, }, }, };
proxy 代理的原理
-
代理可以解决浏览器跨域请求的问题:
- 服务器之间是不存在跨域的,可以使用 NodeJS 创建一个客户端代理,由它代替浏览器客户端直接向服务端发送请求;
- 浏览器客户端可以将发送给服务端的请求发送给客户端代理,由客户端代理转为发送,解决跨域问题;
-
代理图解
-
示例代码
JavaScriptJavaScriptHTML// 服务端 const http = require('http') const server = http.createServer((req, res) => { const arr = [] req.on('data', chunk => { arr.push(chunk) }) req.on('end', () => { console.log(Buffer.concat(arr).toString()) res.end('获取到了客户端的数据') }) }) server.listen(1234, () => { console.log('外部服务端启动了') })
// 客户端代理 const http = require('http') const options = { host: 'localhost', port: 1234, path: '/', method: 'POST' } const server = http.createServer((request, response) => { const req = http.request(options, res => { const arr = [] res.on('data', chunk => { arr.push(chunk) }) res.on('end', () => { const ret = Buffer.concat(arr).toString() response.setHeader('content-type', 'text/html;charset=utf-8') response.end(ret) }) }) req.end('你好张三') }) server.listen(1000, () => { console.log('本地服务端启动了') })
<!-- 浏览器访问 localhost: 1000 --> <!-- 控制台:'获取到了客户端的数据' -->
跨域 - CORS
概述
-
CORS 是基于 http 1.1 的一种跨域解决方案,它的全称是 Cross-Origin Resource Sharing 跨域资源共享;
-
它的总体思路是:如果浏览器要跨域访问服务器的资源,需要获得服务器的允许 ,而一个请求可以附带很多信息,从而会对服务器造成不同程度的影响,比如有的请求只是获取一些新闻,有的请求会改动服务器的数据,针对不同的请求,CORS 规定了三种不同的交互模式,分别是:
- 简单请求
- 需要预检的请求
- 附带身份凭证的请求
-
这三种模式从上到下层层递进,请求可以做的事越来越多,要求也越来越严格;
简单请求
-
当浏览器端运行了一段 ajax 代码(无论是使用 XMLHttpRequest 还是 fetch api),浏览器会首先判断它属于哪一种请求模式
-
当请求 同时满足 以下条件时,浏览器会认为它是一个简单请求:
- 请求方法属于下面的一种:get、post、head
- 请求头仅包含安全的字段,常见的安全字段如下:Accept、Accept-Language、Content-Language、Content-Type、DPR、Downlink、Save-Data、Viewport-Width、Width
- 请求头如果包含 Content-Type 且仅限下面的值之一:text/plain、multipart/form-data、application/x-www-form-urlencoded
- 下面是一些例子:
// 简单请求 fetch('http://crossdomain.com/api/news'); // 请求方法不满足要求,不是简单请求 fetch('http://crossdomain.com/api/news', { method: 'PUT' }); // 加入了额外的请求头,不是简单请求 fetch('http://crossdomain.com/api/news', { headers: { a: 1 } }); // 简单请求 fetch('http://crossdomain.com/api/news', { method: 'post' }); // content-type 不满足要求,不是简单请求 fetch('http://crossdomain.com/api/news', { method: 'post', headers: { 'content-type': 'application/json' }, });
-
当浏览器判定某个 ajax 跨域请求 是 简单请求 时,会发生以下的事情;
- 请求头中会自动添加 Origin 字段
- 比如,在页面 http://my.com/index.html 中有以下代码造成了跨域
// 简单请求 fetch('http://crossdomain.com/api/news');
- 请求发出后,请求头会是下面的格式:
GET /api/news/ HTTP/1.1 Host: crossdomain.com Connection: keep-alive ... Referer: http://my.com/index.html # 告诉服务器,是哪个源地址在跨域请求 Origin: http://my.com
- 比如,在页面 http://my.com/index.html 中有以下代码造成了跨域
- 服务器响应头中应包含 Access-Control-Allow-Origin
- 当服务器收到请求后,如果允许该请求跨域访问,需要在响应头中添加 Access-Control-Allow-Origin 字段;
- 该字段的值可以是:
# 表示什么人我都允许访问 Access-Control-Allow-Origin: * # 表示我就允许 http://my.com 访问 Access-Control-Allow-Origin: http://my.com
- 请求头中会自动添加 Origin 字段
需要预检的请求
-
简单的请求对服务器的威胁不大,所以允许使用上述的简单交互即可完成,但如果浏览器不认为这是一种简单请求,就会按照下面的流程进行:
- 浏览器发送预检请求,询问服务器是否允许
- 服务器允许
- 浏览器发送真实请求
- 服务器完成真实的响应
-
比如,在页面 http://my.com/index.html 中有以下代码造成了跨域
// 需要预检的请求 fetch('http://crossdomain.com/api/user', { method: 'POST', // post 请求 headers: { // 设置请求头 a: 1, b: 2, 'content-type': 'application/json', }, body: JSON.stringify({ name: '袁小进', age: 18 }), // 设置请求体 });
-
浏览器发现它不是一个简单请求,则会按照下面的流程与服务器交互
- 浏览器发送预检请求,询问服务器是否允许
OPTIONS /api/user HTTP/1.1 Host: crossdomain.com ... Origin: http://my.com Access-Control-Request-Method: POST Access-Control-Request-Headers: a, b, content-type # 1. 请求中不包含请求头,也没有消息体,这是一个预检请求,它的目的是询问服务器,是否允许后续的真实请求 # 2. 预检请求有以下特征: # - 请求方法为 `OPTIONS` # - 没有请求体 # - 请求头中包含 # - `Origin`:请求的源,和简单请求的含义一致 # - `Access-Control-Request-Method`:后续的真实请求将使用的请求方法 # - `Access-Control-Request-Headers`:后续的真实请求会改动的请求头
- 服务器允许:服务器收到预检请求后,可以检查预检请求中包含的信息,如果允许这样的请求,需要响应下面的消息格式
HTTP/1.1 200 OK Date: Tue, 21 Apr 2020 08:03:35 GMT ... Access-Control-Allow-Origin: http://my.com Access-Control-Allow-Methods: POST Access-Control-Allow-Headers: a, b, content-type Access-Control-Max-Age: 86400 ... # 对于预检请求,不需要响应任何的消息体,只需要在响应头中添加: # - `Access-Control-Allow-Origin`:和简单请求一样,表示允许的源 # - `Access-Control-Allow-Methods`:表示允许的后续真实的请求方法 # - `Access-Control-Allow-Headers`:表示允许改动的请求头 # - `Access-Control-Max-Age`:告诉浏览器,多少秒内,对于同样的请求源、方法、头,都不需要再发送预检请求了
- 浏览器发送真实请求:预检被服务器允许后,浏览器就会发送真实请求了,上面的代码会发生下面的请求数据
POST /api/user HTTP/1.1 Host: crossdomain.com Connection: keep-alive ... Referer: http://my.com/index.html Origin: http://my.com {"name": "张三", "age": 18 }
- 服务器响应真实请求
HTTP/1.1 200 OK Date: Tue, 21 Apr 2020 08:03:35 GMT ... Access-Control-Allow-Origin: http://my.com ... 添加用户成功
- 浏览器发送预检请求,询问服务器是否允许
-
可以看出,当完成预检之后,后续的处理与简单请求相同,下图简述了整个交互过程
附带身份凭证的请求
-
默认情况下 ajax 的跨域请求并不会附带 cookie ,这样一来,某些需要权限的操作就无法进行;
-
不过可以通过简单的配置就可以实现附带 cookie
// xhr var xhr = new XMLHttpRequest(); xhr.withCredentials = true; // fetch api fetch(url, { credentials: 'include', });
-
这样一来,该跨域的 ajax 请求就是一个附带身份凭证的请求 ,当一个请求需要附带 cookie 时,无论它是简单请求,还是预检请求,都会在请求头中添加 cookie 字段,而服务器响应时,需要明确告知客户端:服务器允许这样的凭据;告知的方式也非常的简单,只需要在响应头中添加:Access-Control-Allow-Credentials: true 即可;
-
对于一个附带身份凭证的请求,若服务器没有明确告知,浏览器仍然视为跨域被拒绝,另外要特别注意的是:对于附带身份凭证的请求,服务器不得设置 Access-Control-Allow-Origin: * ,这就是为什么不推荐使用
*
的原因;
一个额外的补充
-
在跨域访问时 JS 只能拿到一些最基本的响应头,如:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma ,如果要访问其他头,则需要服务器设置本响应头;
-
Access-Control-Expose-Headers 头让服务器把允许浏览器访问的头放入白名单,例如:
Access-Control-Expose-Headers: authorization, a, b
-
这样 JS 就能够访问指定的响应头了;
代码实现
// 后端代码:http://localhost:8888
const http = require('http');
const url = require('url');
let server = http.createServer()
server.on('request', (req, res) => {
let { pathname } = url.parse(req.url);
// 配置跨域
res.setHeader('Access-Control-Allow-Origin', req.headers.origin); // 允许任何网站(源)访问
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization'); // 允许携带的 header
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT'); // 允许的请求方式
// 设置当前 options 的发送频率(一般 30 分钟)
res.setHeader('Access-Control-Max-Age', '10'); // 10s 之后再发送 options 请求
// 预检请求: 先发一个尝试的请求,如果能跑通在发送真正的请求
// 如果碰到 options 请求,直接成功即可
if (req.method === 'OPTIONS') {
res.statusCode = 200;
res.end(); // 内部会自己判断 是否增加了跨域头
}
res.end('get user')
});
server.listen(8888, () => { console.log(`Server running at http://localhost:8888/`) });
<!-- 前端代码:http://127.0.0.1:5500/proxy/proxy.html -->
<body>
<div id="get">get请求</div>
</body>
<script>
get.addEventListener('click', function () {
let xhr = new XMLHttpRequest;
xhr.open('get', 'http://127.0.0.1:8888/user?name=张三');
xhr.send();
});
</script>
跨域 - JSONP
-
在 CORS 出现之前,人们想了一种奇妙的办法来实现跨域,这就是 JSONP;要实现 JSONP 需要浏览器和服务器来一个天衣无缝的绝妙配合;利用了 script、img、iframe 等有 src 属性的标签不存在跨域请求限制的特点,实现跨域请求;
-
实现原理
- 基于 script 标签,向跨域服务器发送请求,并把本地的一个全局函数 result 传给服务器,拼接在 url 中;
- 服务端收到请求,查询出结果并返回数据,返回
result(${JSON.stringify(data)})
字符串; - 浏览器收到返回的字符串,解析执行全局函数 result;
-
缺点:
- Jsonp 只支持 GET 请求,而不支持 POST 等其它类型的 HTTP 请求;
- Jsonp 在调用失败的时候不会返回各种 HTTP 状态码;
- Jsonp 安全性不够,假如提供 Jsonp 的服务存在页面注入漏洞,即它返回的 javascript 的内容被人控制,那么结果是什么?所有调用这个 Jsonp 的网站都会存在漏洞,于是无法把危险控制在一个域名下,所以在使用 Jsonp 的时候必须要保证使用的 Jsonp 服务必须是安全可信的;
-
示例
HTMLJavaScript<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> </head> <body> <button>点击获取用户</button> <script> function result(resp) { console.log(resp); } function request(url) { const script = document.createElement('script'); script.src = url; script.onload = function () { script.remove(); }; document.body.appendChild(script); } document.querySelector('button').onclick = function () { // 将自定义的回调函数名 result 传入 callback 参数中 request("http://localhost:8000/api/user?callback=result&_=${new Date().getTime()}"); }; </script> </body> </html>
const express = require('express'); const app = express(); const path = '/api/user'; const users = [ { name: 'monica', age: 17, sex: 'female' }, { name: '张三', age: 27, sex: 'male' }, ]; app.get(path, (req, res) => { let { callback } = req.query; res.setHeader('content-type', 'text/javascript'); res.send(`${callback}(${JSON.stringify(users)})`); }); const port = 8000; app.listen(port, () => { console.log(`server listen on ${port}`); console.log(`request for users: http://localhost:${port}${path}`); });
web socket
postMessage
-
window.postMessage() 允许来自一个文档的脚本可以传递文本消息到另一个文档里的脚本,而不用管是否跨域,一个文档里的脚本还是不能调用在其他文档里方法和读取属性,但可以用这种消息传递技术来实现安全的通信;这项技术称为 “跨文档消息传递”,又称为 “窗口间消息传递” 或者 “跨域消息传递”;
-
语法:postMessage(message, targetOrigin, [transfer])
- message:要发送的数据,它将会被结构化克隆算法序列化,所以无需自己序列化;
- targetOrigin:指定哪些窗口能接收到消息事件,其值可以是字符串 * (表示无限制) 或者一个 URI (如果要指定和当前窗口同源的话可设置为 “/”);在发送消息的时候,如果目标窗口的协议、主机地址或端口号这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会发送;
-
使用场景
- 页面和其打开的新窗口的数据传递;
- 页面与嵌套的 iframe 消息传递;
- 多窗口之间消息传递;
-
安全问题
- 如果希望从其他网站接收 message,请不要为 message 事件添加任何事件监听器;
- 如果希望从其他网站接收 message,请始终使用 origin 和 source 属性验证发件人的身份;
- 当使用 postMessage 将数据发送到其他窗口时,始终指定精确的目标 origin 而不是 *;
-
代码实现
HTMLHTML<!-- a.html http://127.0.0.1:5500--> <body> <iframe id="iframe" src="http://127.0.0.1:5501/b.html"></iframe> </body> <script> var iframe = document.getElementById('iframe'); iframe.onload = function () { // 向 domain2 发送跨域数据 iframe.contentWindow.postMessage('a.html 发出的消息', 'http://127.0.0.1:5501'); }; // 接受 domain2 返回数据 window.addEventListener('message', (e) => { console.log(e.data); }, false); </script>
<!-- b.html http://127.0.0.1:5501--> <script> // 接收 domain1 的数据 window.addEventListener('message', (e) => { // data: 从其他 window 传递过来的数据副本 console.log(e.data); // origin: 调用 postMessage 时,消息发送窗口的 origin console.log(e.origin); // source: 发送数据一方 window 对象 console.log(e.source); if (e.origin !== 'http://127.0.0.1:5500') return; // 发送消息给 domain1 window.parent.postMessage('b.html 发送的消息', e.origin); }, false); </script>
window.name + iframe
-
实现原理:每个窗口都有自己独立的 window.name,当修改一个窗口的 src 属性的时候,不会影响这个窗口的 window.name 属性;window.name 可以存储不超过 2M 的数据,传递的数据都会变成 string 类型;
-
具体实现:
- A 域:a.html、proxy.html;
- B 域:b.html;
- 若 a 想要拿到 b 的数据,需要把 iframe 的指向重新指回 A 相同的源下 proxy.html;
-
实现代码
HTMLHTMLHTML<!-- a.html http://127.0.0.1:5500/proxy/a.html --> <script> var proxy = function (url, callback) { var state = 0; var iframe = document.createElement('iframe'); iframe.src = url;// 加载跨域页面 document.body.appendChild(iframe); // onload 事件会触发 2 次: // 第 1 次加载跨域页 b.html,并留存数据于 window.name // 第 2 次加载同域页 proxy.html,获取 window.name iframe.onload = function () { if (state === 0) { // 第 1 次 onload 成功后,切换到同域代理页面 // 想要正常拿到 B 中的内容,还需要把 iframe 的指向,重新指回 A 相同的源下 iframe.contentWindow.location = 'http://127.0.0.1:5500/proxy/proxy.html'; state = 1; } else if (state === 1) { // 第 2 次 onload(同域 proxy 页)成功后,读取同域 window.name 中数据 callback(iframe.contentWindow.name); // 获取数据以后销毁这个 iframe,释放内存,也保证了安全(不被其他域访问) iframe.contentWindow.document.write(''); iframe.contentWindow.close(); document.body.removeChild(iframe); } }; }; // 请求跨域 b 页面数据 proxy('http://127.0.0.1:5501/b.html', function (data) { alert(data); }); </script>
<!-- proxy.html http://127.0.0.1:5500/proxy/proxy.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> </head> <body> </body> </html>
<!-- b.html http://127.0.0.1:5501/b.html --> <script type="text/javascript"> var person = { name: "张三", age: "26", } window.name = JSON.stringify(person) </script>
window.location.hash + iframe
-
该方法跟 window.name 类似;
-
具体实现
- 访问 a.html 会加载 b.html,并把值放在 b 的 iframe 的 hash 中;
- 然后 b 载入后,会加载 c.html,并把值放在 c 的 iframe 的 hash 中;
- 而 a 和 c 是同域的,那么也就是说 c 的 hash 可以直接复制给 a 的 hash,这样 a 就得到了 b 的值;
-
代码实现
HTMLHTMLHTML<!-- a.html http://localhost:5500 --> <body> <iframe id="iframe" src="http://127.0.0.1:5501/B.html" style="display:none;"></iframe> </body> <script> let iframe = document.getElementById('iframe'); // => 向 B.html传 hash值 iframe.onload = function () { // 后面多传了一个哈希值 name=zhangsan iframe.src = 'http://127.0.0.1:5501/B.html#name=zhangsan'; } function func(res) { alert(res); } </script>
<!-- b.html http://localhost:5501 --> <body> <iframe id="iframe" src="http://127.0.0.1:5500/C.html" style="display:none;"></iframe> </body> <script> let iframe = document.getElementById('iframe'); // => 监听 A 传来的 HASH 值改变,再传给 C.html window.onhashchange = function () { iframe.src = "http://127.0.0.1:5500/C.html" + location.hash; } </script>
<!-- c.html http://localhost:5500 --> <script> // => 监听 B 传来的 HASH 值 window.onhashchange = function () { // => 再通过操作同域 A 的 js 回调,将结果传回 window.parent.parent.func(location.hash); }; </script>
document.domain + iframe
-
document.domain 用来得到当前网页的域名
- 比如在百度页面控制台中输入:
alert(document.domain); //"www.baidu.com"
- 也可以给 document.domain 属性赋值,不过是有限制的,只能赋成当前的域名或者一级域名,比如:
alert(document.domain = "baidu.com"); //"baidu.com" alert(document.domain = "www.baidu.com"); //"www.baidu.com"
- 上面的赋值都是成功的,因为 www.baidu.com 是当前的域名,而 baidu.com 是一级域名,但是下面的赋值就会出来 “参数无效” 的错误,比如:
alert(document.domain = "qq.com"); //参数无效 报错 alert(document.domain = "www.qq.com"); //参数无效 报错
- 因为 qq.com 与 baidu.com 的一级域名不相同,所以会有错误出现,这是为了防止有人恶意修改 document.domain 来实现跨域偷取数据;
- 比如在百度页面控制台中输入:
-
利用 document.domain 实现跨域
- 前提条件:这两个域名必须属于同一个一级域名,而且所用的协议、端口都要一致,否则无法利用 document.domain 进行跨域,Javascript 出于对安全性的考虑,禁止两个或者多个不同域的页面进行互相操作,而相同域的页面在相互操作的时候不会有任何问题;
- 有两个子域名:news.baidu.com(news.html)、map.baidu.com(map.html)
- news.baidu.com 里的一个网页 (news.html) 引入了 map.baidu.com 里的一个网页 (map.html),这时 news.html 里同样是不能操作 map.html 里面的内容的,因为 document.domain 不一样,一个是 news.baidu.com,另一个是 map.baidu.com;
- 这时就可以通过 Javascript 将两个页面的 domain 改成一样的,需要在 a.html 与 b.html 里都加入 document.domain = “baidu.com”;
- 这样这两个页面就可以互相操作了,也就是实现了同一一级域名之间的 “跨域”;
-
实现代码
HTMLHTML<!-- news.baidu.com 下的 news.html 页面 --> <script> document.domain = 'baidu.com'; var ifr = document.createElement('iframe'); ifr.src = 'map.baidu.com/map.html'; ifr.style.display = 'none'; document.body.appendChild(ifr); ifr.onload = function () { var doc = ifr.contentDocument || ifr.contentWindow.document; // 这里可以操作 map.baidu.com 下的 map.html 页面 var oUl = doc.getElementById('ul1'); alert(oUl.innerHTML); ifr.onload = null; }; </script>
<!-- map.baidu.com 下的 map.html 页面 --> <ul id="ul1">我是map.baidu.com中的ul</ul> <script> document.domain = 'baidu.com'; </script>
面试题
为什么通常在发送数据埋点请求的时候使用的是 1x1 像素的透明 gif 图片?
首先,在很多场景中,处理埋点的服务器很有可能是第三方服务器,比如百度的站点统计埋点,百度就是一个第三方服务器,这就不可避免的带来跨域问题;
其次,埋点服务方需要提供一种特别利于安装的埋点置入代码,使用传统的 ajax 会使代码变得臃肿;
同时,埋点请求绝大部分都是 get 请求,又无须得到服务器的响应结果;
基于以上的特点,使用 img 元素请求服务器就变得理所当然了,img 元素发出的请求天生支持跨域,书写的代码简单,只需要创建一个 img 元素,然后设置 src 为埋点请求地址即可;
其实请求一旦发出,埋点就成功了,无须得到服务器的响应结果;但如果服务器不给予任何响应的话,可能会导致浏览器端控制台报错,尽管这个报错并不影响实质的功能;为了避免这种情况,服务器于是响应一个最小体积的图片即可,而 1x1 像素的透明 gif 图片是体积最小的图片,自然就选用了它作为响应结果;
表单可以跨域吗
Form 表单可以跨域是因为要保持兼容性,当请求到另一个域名后,原页面得脚本无法获取新页面中得内容,提交的 Form 表单数据不需要返回,所以浏览器认为是安全得行为,所以浏览器不会阻止 Form 表单跨域;
ajax 请求到另一个域名后,是需要获取返回的数据,浏览器认为不安全,所以会阻止这个请求行为;
浏览器🧑💻 跨标签页通信
上一篇