同源策略
同源策略(Same origin policy)是浏览器的一种安全策略,用于限制一个 origin(源)的文档或脚本如何与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介(如 XSS、CSFR 等)。拦截的是客户端发出的请求回来的数据接收,即请求发送了,服务器响应了,但是无法被浏览器接收。
所谓同源(即指在同一个域)是指 URL 的”协议+域名+端口”三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。
下表给出了与 URL http://store.company.com/page.html
的源进行对比的示例:
URL | 结果 | 原因 |
---|---|---|
http://store.company.com/other.html | 同源 | 只有路径不同 |
http://store.company.com/inner/another.html | 同源 | 只有路径不同 |
https://store.company.com/page.html | 失败 | 协议不同 |
http://store.company.com:81/page.html | 失败 | 端口不同 ( http:// 默认端口是80) |
http://news.company.com/page.html | 失败 | 主机不同 |
http://www.baidu.com/page.html | 失败 | 域名不同 |
http://192.168.4.12/page.html(对应相同 ip) | 失败 | 主机不同 |
什么是跨域
跨域是由浏览器的同源策限制的一类请求场景,一个域下的文档或脚本试图去请求另一个域下的资源。
下图中是在百度首页发起的请求
可以看到,当在百度首页向淘宝发送请求时会报错,这就是跨域。
跨域解决方案
- JSONP
- document.domain + iframe
- location.hash + iframe
- window.name + iframe
- postMessage
- 跨域资源共享(CORS)
- nginx 代理跨域
- nodejs 中间件代理跨域
- WebSocket 协议跨域
JSONP
JSONP(JSON width Padding)是一个非官方的跨域解决方案。原理是利用一些标签原生就具备跨域能力,比如:img、link、iframe、script 来发送跨域请求的。
例如通过 script
标签会引入一些外部资源(例如 cdn),对这些资源的请求就是一个跨域。
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script>
利用请求过来的脚本浏览器会自动执行的特性,可以将回调函数传递给服务器,由服务器来返回调用。
<script>
function handle(data) {
console.log(data);
}
</script>
<!-- 传递回调函数名给服务器 -->
<script src="http://localhost:8000/jsonp-server?callback=handle"></script>
服务器收到请求后,将数据放在回调函数的参数位置返回
app.get('/jsonp-server', (request, response) => {
// 获取回调函数名
let callback = request.query.callback
// 返回调用,相当于 handle('hello jsonp')
response.end(`${callback}('hello jsonp')`)
})
可以将 JSONP 进行封装,以下是简单实现。
function jsonp(url, data = {}) {
return new Promise((resolve, reject) => {
window.__fn__ = (data) => resolve(data);
let script = document.createElement("script");
let query = Object.entries(data)
.map((a) => `${a[0]}=${a[1]}`)
.join("&");
script.src = url + "?callback=__fn__&" + query;
script.onerror = () => reject("加载失败");
document.body.appendChild(script);
document.body.removeChild(script);
});
}
// 调用
jsonp("http://localhost:8000/jsonp-server")
.then((data) => {
console.log(data);
})
.catch((e) => console.log(e));
jQuery 也实现对了 JSONP 的封装:
$.ajax({
url: 'http://www.domain2.com:8080/login',
type: 'get',
dataType: 'jsonp', // 请求方式为jsonp
jsonpCallback: "handleCallback", // 自定义回调函数名
data: {}
});
因为 JSONP 是通过动态创建 script
实现的,而 scirpt
只能使用 GET,不能使用 POST。
document.domain + iframe
有一个页面 http://www.example.com/a.html
,里面的 iframe
是 http://example.com/b.html
,很显然它们是不同源的,所以两个页面无法进行交互。
可以通过 document.domain
属性将它们设置为同一个源。
<!-- a.html -->
<script>
document.domain = 'example.com';
// 获取 iframe 内容文档
var iframe = document.getElementById('iframe').contentDocument;
</script>
<!-- b.html -->
<script>
document.domain = 'example.com';
// 获取父窗口中变量
alert('get js data from parent ---> ' + window.parent.user);
</script>
document.domain
属性只能设置成自身或更高一级的父域。所以此方案仅限主域相同,子域不同的跨域应用场景。
window.name + iframe
每一个窗口都有一个 window.name
属性,iframe
中同样也有一个独立的 name
属性。该属性有一个特征:即在一个窗口的生命周期内,窗口载入的所有的页面都是共享一个 window.name
,每一个页面对 window.name
都有读写的权限,并不会因为新的页面的载入而被重置,并且可以支持非常大的 name
值(2MB)。
下面就验证一下同一个窗口下,页面重新载入,window.name
仍然不变。
<script>
window.name = "我是A窗口";
setTimeout(function () {
window.location.href = "b.html";
console.log(window.name); // "我是A窗口"
}, 2000);
</script>
如果想不跳转页面来拿到数据,可以用一个隐藏的 iframe
作为中间代理来实现跨域。
<iframe id="iframe" src="https://xxx.github.io/xxx/" onload="load()" style="display:none"></iframe>
<script>
function load() {
var iframe = document.getElementById("iframe");
iframe.onload = function () {
// 获取数据
var data = iframe.contentWindow.name;
console.log(data);
};
iframe.src = "about:blank"; // 让 url 地址改变,与当前页面同源,可以任意写,保持同源就好
}
</script>
还可以对该方法进行一个封装:
var proxy = function(url, callback) {
var state = 0;
var iframe = document.createElement('iframe');
iframe.style.display = 'none'
// 加载跨域页面
iframe.src = url;
// onload 事件会触发 2 次,第 1 次加载跨域页,并留存数据于 window.name
iframe.onload = function() {
if (state === 1) {
// 第 2 次 onload(同域proxy页)成功后,读取同域 window.name 中数据
callback(iframe.contentWindow.name);
destoryFrame();
} else if (state === 0) {
// 第 1 次 onload(跨域页)成功后,切换到同域代理页面
iframe.contentWindow.location = 'about:blank';
state = 1;
}
};
document.body.appendChild(iframe);
// 获取数据以后销毁这个iframe,释放内存;这也保证了安全(不被其他域 frame js访问)
function destoryFrame() {
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
// 请求跨域 b 页面数据
proxy('http://www.domain2.com/b.html', function(data){
alert(data);
});
location.hash + iframe
hash 属性是一个可读可写的字符串,该字符串是 URL 的锚部分(从 # 号开始的部分),改变锚的部分并不会使页面刷新。
假设当前的 URL 是 http://www.runoob.com/test.htm#part2
:
console.log(location.hash) // "#part2"
原理和使用 window.name
相似,只不过数据载体不同,location.hash
是将数据加在 URL 后面。
假设 a.html 与 b.html 不在同一个域,而和 c.html 同域,实现跨域需要以下操作步骤:
- a.html 创建一个
iframe
,将src
指向 b.html
a.html 的代码:
<script type="text/javascript">
function crossDomain() {
var iframe = document.createElement("iframe");
iframe.style.display = "none";
// 此处指向需要跨域的地址
iframe.src = "b.html";
document.body.appendChild(iframe);
}
crossDomain();
// 开放给同域 c.html 的回调方法
function onCallback(data) {
console.log(data);
}
</script>
b.html 的代码:
<script type="text/javascript">
// 需要传输的数据
var crossDomainData = "crossDomainByHash";
// 判断传输的数据是否是字符串
crossDomainData = "#" + (typeof crossDomainData === "string" ? crossDomainData : JSON.stringify(crossDomainData));
// 创建一个与 a.html 同域的 iframe
var proxy = document.createElement("iframe");
proxy.style.display = "none";
proxy.src = "c.html" + crossDomainData;
document.body.appendChild(proxy);
</script>
c.html 的代码:
<script type="text/javascript">
// 因为 a.html 和自身属于同一个域,所以可以访问该方法
parent.parent.onCallback(self.location.hash.substring(1));
</script>
postMessage
postMessage()
方法可以实现跨文本文档、多窗口、跨域消息的传递。只要正确的使用,这种方法就很安全。
从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener
),然后在窗口上调用 targetWindow.postMessage()
方法分发一个 MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件。
postMessage()
方法接受两个参数:
- message
要传递的数据,可以是任意基本类型或可复制的对象,但部分浏览器只能处理字符串参数,所以在传参时最好使用 JSON.stringify()
序列化。
- targetOrigin
将消息发送给指定窗口,其值可以是字符串"*"
(表示无限制),如果要指定和当前窗口同源的话设置为"/"
。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。
同样是对两个跨域窗口的示例:
a.html 代码:
<iframe id="ifr" src="https://ex.tumiblog.top/B.html"></iframe>
<script type="text/javascript">
window.onload = function () {
var iframe = document.getElementById('ifr');
var targetOrigin = 'https://ex.tumiblog.top';
iframe.contentWindow.postMessage('I am A.html!', targetOrigin);
}
</script>
b.html 代码:
<script type="text/javascript">
// 监听 message 事件
window.addEventListener('message', function (event) {
// chrome 浏览器下,origin property 是 event.originalEvent
var origin = event.origin || event.originalEvent.origin;
// 防止恶意源
if (origin !== 'https://www.tumiblog.top') return;
console.log(event.data);
}, false);
</script>
如果您不希望从其他网站接收 message,请不要为 message 事件添加任何事件侦听器。 这是一个完全万无一失的方式来避免安全问题。
如果您确实希望从其他网站接收 message,请始终使用 origin 和 source 属性验证发件人的身份。 任何窗口(包括例如http://evil.example.com)都可以向任何其他窗口发送消息,并且您不能保证未知发件人不会发送恶意消息。 但是,验证身份后,您仍然应该始终验证接收到的消息的语法。 否则,您信任只发送受信任邮件的网站中的安全漏洞可能会在您的网站中打开跨网站脚本漏洞。
当您使用 postMessage 将数据发送到其他窗口时,始终指定精确的目标 origin,而不是 *。 恶意网站可以在您不知情的情况下更改窗口的位置,因此它可以拦截使用 postMessage 发送的数据。
CORS
CORS(Cross-Origin Resource Sharing),跨域资源共享。CORS 是官方的跨域决定方案,它的特点是不需要在客户端做任何特殊的操作,完全在服务器中进行处理,支持 get 和 post 等请求。跨域资源共享标准新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。CORS 已经成为主流的跨域解决方案。
整个 CORS 通信过程,都是浏览器自动完成,浏览器一旦发现 AJAX 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求(option),但用户不会有感觉。
浏览器将 CORS 请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。浏览器对这两种请求的处理,是不一样的。
只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
对于简单请求,浏览器会在请求头中添加 Origin 字段,该字段用来说明请求来自哪个源(协议+域名+端口)。浏览器会根据这个值来决定是否同意这次请求。
GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
如果服务器允许该源跨域,需要在返回的响应头中携带 Access-Control-Allow-Origin 信息。浏览器会根据这个信息来判断服务器是否允许跨域。
// node.js
app.get('/server', (request, response) => {
// 允许跨域
response.header('Access-Control-Allow-Origin', '*')
// 允许发送 Cookie
response.header('Access-Control-Allow-Credentials', true)
// 暴露 FooBar 首部
response.header('Access-Control-Expose-Headers', 'FooBar')
})
以上的头信息中,都以 Access-Control- 开头。
(1)Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个*,表示接受任意域名的请求。
(2)Access-Control-Allow-Credentials
该字段可选。它的值是一个布尔值,表示是否允许发送 Cookie。默认情况下,Cookie 不包括在 CORS请求之中。设为 true,即表示服务器明确许可,Cookie可 以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送 Cookie,删除该字段即可。
另一方面,浏览器发起 ajax 需要指定 withCredentials
为 true
。
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
需要注意的是,如果要发送 Cookie,Access-Control-Allow-Origin
就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie 依然遵循同源政策,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨源)原网页代码中的 document.cookie
也无法读取服务器域名下的 Cookie。
非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为”预检”请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest
请求,否则就报错。
下面是这个”预检”请求的 HTTP 头信息。
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
“预检”请求用的请求方法是 OPTIONS
,表示这个请求是用来询问的。头信息里面,关键字段是 Origin
,表示请求来自哪个源。
除了 Origin
字段,”预检”请求的头信息包括两个特殊字段。
(1)Access-Control-Request-Method
该字段是必须的,用来列出浏览器的 CORS 请求会用到哪些 HTTP方法,上例是 PUT
。
(2)Access-Control-Request-Headers
该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段,上例是 X-Custom-Header
。
服务器收到”预检”请求以后,检查了 Origin
、Access-Control-Request-Method
和 Access-Control-Request-Headers
字段以后,确认允许跨源请求,就可以做出回应。
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
除了 Access-Control-Allow-Origin
和 Access-Control-Allow-Credentials
以外,这里又额外多出两个头:
- Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。
- Access-Control-Allow-Headers
如果浏览器请求包括 Access-Control-Request-Headers
字段,则 Access-Control-Allow-Headers
字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。
更多 CORS 头可以查看 MDN 文档。如果浏览器得到上述响应,则认定为可以跨域,后续就跟简单请求的处理是一样的了。