浏览器的同源策略是一个用于隔离潜在恶意文件的重要安全机制,其限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。
本文介绍了同源策略的几个方面,以及如何避免。
概述
什么是同源
所谓的“同源”, 指的是“三个相同”,即:
- 协议相同
- 域名相同
- 端口相同
举例来说,https://www.a.com/bbb/ccc.html
这个网址,协议为https://
,域名为www.a.com
,端口为80
(默认的80端口可省略)。以下几个网址与之的同源情况为:
- https://www.a.com/xxx/yyy.html -> 同源
- http://www.a.com/bbb/ccc.html -> 不同源,协议不同
- https://m.a.com/bbb/ccc.com -> 不同源,域名不同
- https://www.a.com:8080/bbb/ccc.html -> 不同源,端口不同
同源的目的
同源策略的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。
同源的限制范围
目前,如果非同源,共有三种行为受到限制。
- Cookie、LocalStorage 和 IndexDB 无法读取。
- DOM 无法获得。
- AJAX 请求不能发送。
以下将分别介绍如何避免这三个限制
Cookies
服务器写入浏览器的一小段信息,只有同源的网页才能共享。
但是,如果两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain
共享 Cookie。
举例来说,A网页是https://www.a.com/bbb/ccc.html
,B网页是https://m.a.com/xxx/yyy.html
,那么,只要设置相同的document.domain
,这两个网页就可以共享Cookies了。
1 | //为两个页面设置相同的 document.domain |
现在,A网页内通过脚本设置一个Cookies
1 | document.cookies = "test1=hello"; |
B网页就可以读取到这个Cookies了
1 | var allCookies = document.cookies; // test1=hello;test2=world |
注意:document.domain
不能随意设置,只能把document.domain
设置成自身或更高一级的父域。
1 | // https://www.a.com/bbb/ccc.html页面下 |
此外,这种方法只适用于Cookie和iframe窗口,LocalStorage和IndexDB无法通过这种方法来规避同源策略,而要使用下文介绍的PostMessage API。
另外,服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.a.com
。
1 | Set-Cookie: key=value; domain=.a.com; path=/ |
这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie。
PS:如果两个网址,协议不同或端口不同,其Cookies可共享,即:Cookie共享跟协议、端口无关
跨域文档通信
如果两个网页不同源,就无法拿到对方的
DOM
,也无法进行通信。典型的例子是iframe
窗口和window.open
方法打开的窗口,它们与父窗口无法通信。
比如,父窗口运行下面的命令,如果iframe
窗口不是同源,就会报错。
1 | document.getElementById("myIFrame").contentWindow.document |
上面命令中,父窗口想获取子窗口的DOM,因为跨源导致报错。
反之亦然,子窗口获取主窗口的DOM也会报错。
1 | window.parent.document.body |
如果两个窗口一级域名相同,只是二级域名不同,那么设置上一节介绍的document.domain
属性,就可以规避同源策略,拿到DOM
。
对于完全不同源的网站,目前有三种方法,可以解决跨域窗口的通信问题。
- 片段识别符(fragment identifier)
- window.name
- window.postMessage
片段识别符(fragment identifier)
片段标识符(fragment identifier)指的是,URL的
#
号后面的部分,比如https://www.a.com/bbb/ccc.html#fragment
的#fragment
。如果只是改变片段标识符,页面不会重新刷新。
父窗口可以把信息,写入子窗口的片段标识符。
1 | var src = originURL + '#' + data; |
子窗口通过监听hashchange
事件得到通知。
1 | window.onhashchange = checkMessage; |
同样的,子窗口也可以改变父窗口的片段标识符。
1 | parent.location.href= target + "#" + hash; |
window.name
浏览器窗口有window.name
属性。这个属性的最大特点是,只要当前的这个浏览器tab
没有关闭,无论tab
内的网页如何变动,这个name
值都可以保持,并且tab
内的每个网页都是可以接收和设置window.name
这个值的。
- F5刷新多少次都可以,这是无所谓的。
- 中间跳转过多少个页面,这也是无所谓的。
- 承载过的这些页面是不是同一个域名,这都是无所谓的。
上述的tab
,改成iframe
同样可行。
父窗口先打开一个子窗口,载入一个不同源的网页,该网页将信息写入window.name
属性。
1 | // 在子窗口中设置其window.name |
然后,主窗口就可以读取子窗口的window.name
了。
1 | // 主窗口中 |
这种方法的优点是,window.name
容量很大,可以放置非常长的字符串;缺点是必须监听子窗口window.name
属性的变化,影响网页性能。
window.postMessage
在HTML5中,为了实现跨源通信,引入了一个全新的API:跨文档通信 API(Cross-document messaging)。
这个API为window
对象新增了一个window.postMessage
方法,允许跨窗口通信,不论这两个窗口是否同源。
其语法为:
otherWindow.postMessage(message, targetOrigin, [transfer]);
-
otherWindow
其他窗口的一个引用,比如iframe
的contentWindow
属性、执行window.open
返回的窗口对象、或者是命名过或数值索引的window.frames
。 -
message
将要发送到其他window
的数据。该参数可传入一个Object
. -
targetOrigin
通过窗口的origin
属性来指定哪些窗口能接收到消息事件,其值可以是字符串*
(表示无限制)或者一个URI
。在发送消息的时候,如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin
提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。这个机制用来控制消息可以发送到哪些窗口;例如,当用postMessage
传送密码时,这个参数就显得尤为重要,必须保证它的值与这条包含密码的信息的预期接受者的origin
属性完全一致,来防止密码被恶意的第三方截获。如果你明确的知道消息应该发送到哪个窗口,那么请始终提供一个有确切值的targetOrigin
,而不是*
。不提供确切的目标将导致数据泄露到任何对数据感兴趣的恶意站点。 -
transfer 可选
是一串和message
同时传递的Transferable
对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权。
举例来说,父窗口http://a.com
向子窗口http://b.com
发消息,调用postMessage
方法就可以了。
1 | // 父窗口中 |
子窗口向父窗口发送消息的写法类似。
1 | // 子窗口中 |
父窗口和子窗口都可以通过message事件,监听对方的消息。
1 | window.addEventListener('message', function(event) { |
message
事件的事件对象event
,提供以下三个属性。
- event.source:对发送消息的窗口对象的引用;可以使用此来在具有不同
origin
的两个窗口之间建立双向通信。- event.origin:调用
postMessage
时消息发送方窗口的origin
。这个字符串由协议
、://
、域名
、:端口号
拼接而成。这个origin
不能保证是该窗口的当前或未来origin
,因为postMessage
被调用后可能被导航到不同的位置。- event.data:从其他
window
中传递过来的消息内容对象。
子窗口可以通过event.source
属性引用父窗口,然后发送消息。
1 | // 子窗口中 |
event.origin
属性可以过滤不是发给本窗口的消息。
1 | window.addEventListener('message', receiveMessage); |
注意:任何窗口可以在任何其他窗口访问此方法,在任何时间,无论文档在窗口中的位置,向其发送消息。 因此,用于接收消息的任何事件监听器必须
首先使用origin
和source
属性来检查消息的发送者的身份。无法检查origin和source属性会导致跨站点脚本攻击。
因为有了window.postMessage
,读写其他窗口的LocalStorage
也成为了可能。
下面是一个例子,主窗口写入iframe子窗口的localStorage
。
1 | // 子窗口中 |
上面代码中,子窗口将父窗口发来的消息,写入自己的localStorage
。
父窗口发送消息的代码如下
1 | var win = document.getElementsByTagName('iframe')[0].contentWindow; |
加强版的子窗口接收消息的代码如下
1 | window.onmessage = function(e) { |
加强版的父窗口发送消息代码如下
1 | var win = document.getElementsByTagName('iframe')[0].contentWindow; |
AJAX
同源策略规定,AJAX请求只能发给同源的网址,否则就报错。
规避这个限制有以下几种方法:
- 使用Flash插件发送HTTP请求
- 架设服务器代理
- JSONP
- WebSocket
- CORS
使用Flash插件发送HTTP请求
这种方式可以绕过浏览器的安全限制,但必须安装Flash,并且跟Flash交互。不过Flash用起来麻烦,而且现在用得也越来越少了。
架设服务器代理
在同源域名下架设一个代理服务器来转发,JavaScript负责把请求发送到代理服务器:
1 | '/proxy?url=http://www.sina.com.cn' |
代理服务器再把结果返回,这样就遵守了浏览器的同源策略。这种方式麻烦之处在于需要服务器端额外做开发。
JSONP
JSONP是服务器与客户端跨源通信的常用方法。最大特点就是简单适用,老式浏览器全部支持,服务器改造非常小。
它的基本思想是,网页通过添加一个<script>
标签,向服务器请求JSON数据,这种做法不受同源策略限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。它有个限制,只能
用GET
请求,并且要求返回JavaScript。(因为<script>
标签只能使用GET
加载资源)
JSONP通常以函数调用的形式返回。例如,调用某一接口,返回JavaScript内容如下:
1 | foo({test: "hello"}); |
因此我们需要首先在页面中准备好回调函数:
1 | function foo(data){ |
然后调用另一函数getData()
触发:
1 | function getPrice() { |
由于<script>
标签请求的脚本,直接作为代码运行。这时,只要浏览器定义了foo
函数,该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用JSON.parse
的步骤。
WebSocket
WebSocket是一种通信协议,使用ws://
(非加密)和wss://
(加密)作为协议前缀。该协议不实行同源策略,只要服务器支持,就可以通过它进行跨源通信。
下面是一个例子,浏览器发出的WebSocket请求的头信息(摘自维基百科)。
1 | GET /chat |
上面代码中,有一个字段是Origin
,表示该请求的请求源(origin),即发自哪个域名。
正是因为有了Origin
这个字段,所以WebSocket才没有实行同源策略。因为服务器可以根据这个字段,判断是否许可本次通信。如果该域名在白名单内,服务器就会做出如下回应。
1 | 101 Switching Protocols |
CORS
CORS全称Cross-Origin Resource Sharing,是HTML5规范定义的如何跨域访问资源,是跨源AJAX请求的根本解决方法。相比JSONP只能发GET
请求,CORS允许任何类型的请求。
Origin
表示本域,也就是浏览器当前页面的域。当JavaScript向外域(如sina.com
)发起请求后,浏览器收到响应后,首先检查Access-Control-Allow-Origin
是否包含本域,如果是,则此次跨域请求成功,如果不是,则请求失败,JavaScript将无法获取到响应的任何数据。
详细的介绍,可以参考此文
参考
浏览器同源政策及其规避方法 - 阮一峰
浏览器的同源策略 - MDN
window.postMessage - MDN
window.name 跨域隐式传递消息原理解析 - 苏南大叔
AJAX - 廖雪峰