浏览器的同源策略详解

一、同源策略

同源策略是一种约定,是浏览器最核心也最基本的安全功能,主要体现在同源策略会限制来自不同源的文档和脚本对当前源的文档数据的读取或设置某些属性,是用于隔离潜在恶意文件的重要安全机制。

二、如何确定一个源

只要满足下面三项相同,则可以确定两个页面是来自同一个源的

协议、域名、端口

下面给出相对http://abc.jjp.com/app/page.html的同源检测结果

url 结果 原因
http://abc.jjp.com/app/page2.html 成功
http://abc.jjp.com/source/page2.html 成功
https://abc.jjp.com/app/dist/page3.html 失败 协议不相同
http://efg.jjp.com/app/page2.html 失败 域名不相同
http://abc.jjp.com:8080/app/page2.html 失败 端口不相同

三、源的继承

about:blank,javascript:中的内容,继承了将其载入的文档的源,因为这些伪协议的URL并没有明确地包含有关服务器的源的信息。当调用window.open()打开一个about:blank页面时,若该页面有代码,则会继承创建该页面的代码的源。而data:URLs则会重新得到一个新的空的安全的上下文,不会继承源

注意:在Gecko 6.0之前,如果用户在地址栏中输入 data URLs,data URLs 将继承当前浏览器窗口中网页的安全上下文

四、IE是例外

在同源策略中,Internet Explorer有两点不同

  • 授信范围(Trust Zones):两个相互之间高度互信的域名,如公司域名 (corporate domains),不遵守同源策略的限制。
  • 端口:未将端口号加入到同源策略的组成部分之中,因此 http://abc.com:81/index.htmlhttp://abc.com/index.html 属于同源并且不受任何限制。

五、修改源

页面可以改变本身的源,但是会有一些限制。脚本可以将 document.domain设置为当前域或者当前域的超级域,该较短的域会用于后续源检查。

假如当前页面http://abc.jjp.com/index.html文档中执行如下脚本,将当前域设置当前域的超级域

document.domain = 'jjp.com'

设置完之后,该页面会通过http://jjp.com/page.html的同源检查,同时abc.jjp.com不能设置为efg.jjp.com,因为efg.jjp.com不是abc.jjp.com的超级域

如果存在端口号不一致,想通过document.domain设置的方式来通过同源检查的话需要双方都进行设置。因为设置document.domain会导致端口号被重写为null。如果jjp.com:8080想要与jjp.com通信,把jjp.com:8080页面的document.domain设置为jjp.com时端口号会被重写为null,而原来的jjp.com的端口号为80,则还是不能够通过同源检测,需要双方同时设置document.domain让双方端口号都为null。

注意:document.domain能够让子域访问其父域,但是需要同时将子域和父域的document.domain设置为相同的值。这是必要的,即使是简单的将父域设置为其原来的值。不这么做的话可能导致权限错误

六、不同源之间的交互

同源策略控制了不同源的交互,主要有三类交互

  • 跨域写:通常允许,比如链接重定向表单提交(因为表单提交不需要反馈数据)
  • 跨域资源嵌入:通常允许,下面会给出跨域资源嵌入的例子
  • 跨域读:通常不允许,比如在使用XMLHttpRequest的时候会发生跨域问题,不过通过某些方法仍可以进行跨域读

跨域资源嵌入的例子

  • <script src="..."></script>标签嵌入外域的脚本,且该脚本的错误不能在本源中捕获
  • <link rel= "stylesheet" href="...">标签嵌入外域的css文件,由于CSS的松散的语法规则,CSS的跨域需要一个设置正确的Content-Type 消息头,不同浏览器有不同的限制。
  • <img>嵌入外域的图片
  • <video>和<audio>标签嵌入外域的多媒体资源
  • <object>和<embed>的插件
  • @font-face引入的字体,一些浏览器允许引入外域字体,一些浏览器则不允许
  • <iframe> 载入的任何资源,站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互

七、如何避免跨域访问

  • 避免跨域写:在发起写请求中携带一个隐藏的token,然后服务器端对这个token进行验证,多用来防范CSRF攻击
  • 避免跨域读:要保证返回给客户端的资源是不可嵌入的,不可以是上面列出的允许跨域资源嵌入的标签
  • 避免跨域资源嵌入:需要确保html文档中没有上面列出的允许跨域资源嵌入的标签

八、跨源文档API的访问

javascript的api中,允许文档间互相引用,如 iframe.contentWindowwindow.parentwindow.openwindow.opener,这些api可以拿到其他文档的对象的引用,但是当两个文档不同源时,对该对象(如WindowLocation)的访问就会有所限制。如果想要两个不同源的窗口进一步交流可以使用window.postMessage

九、跨源数据存储访问

localStorageIndexedDB等数据存储会以源进行分割,每个源拥有自己独立的存储空间,一个源的js脚本不能对属于其他源的数据进行读写操作

cookies同样只有同源网页才能共享,设置其domain、path、secure、HttpOnly属性可以来限定其访问性

属性 作用
domain 指定cookies对哪个域有效,cookies只会发向该域,默认值是设置cookie的那个域
path 表示相对于domain的路径,只有在该路径下才能拿到cookies,默认值为/
secure 设置了该属性或者设置了'secure=true'表示只能在 HTTPS 连接中传递cookies
HttpOnly 设置了该属性或这设置了'HttpOnly=true'表示js脚本不能读取到cookie信息

十、实现跨域读取的方案

1.XMLHttpRequest的跨域
方案:
  1.JSONP
  2.CORS
  3.WebSocket
  4.代理

方案1:JSONP

只能用于Get请求,老式浏览器都支持。在网页中创建一个<script>标签,src为请求的url,请求的查询字符串有一个callback参数,用来指定回调函数的名称,回调函数在js脚本中声明好。当服务器收到请求后,返回一句js脚本,内容是将json数据作为参数传入回调函数并调用该函数。

实例:

前端:
var jsonp = {
    exec: function() {
        var script = document.getElementById('jsonp');
        if(script) {
            script.parentElement.removeChild(script);
        }
        //创建<script>标签
        script = document.createElement('script');
        script.id = 'jsonp';
        //返回js脚本:
        //jsonp.jsonpcallback({"code":1000,"data":{"username":"carl","userAge":20,"userSex":"男"}})
        script.src = 'http://localhost:8080/getusermsg?callback=jsonp.jsonpcallback';
        document.head.appendChild(script);        
    },
    //返回js脚本时会调用该函数
    jsonpcallback: function (userdata) { 
        alert('姓名:' + userdata.data.username);
        alert('年龄:'+ userdata.data.userAge);
        alert('性别:' + userdata.data.userSex);
    }
 }
 $('#btn1').click(jsonp.exec);

服务端:
function getusermsg(req, res, next) {
    if(req.url.match(api.getusermsg)) {
        var queryJson = queryParse(req.url.split('?')[1]); 
        var fb = {code: 1000, data: {username: 'carl', userAge: 20, userSex: '男'}};
        //查询字符串中有callback参数
        if(queryJson.callback) {
            res.writeHead(200);
            //返回调用回调函数的字符串,前端以js脚本来解析并执行
            res.write(queryJson.callback + '(' + JSON.stringify(fb) + ')');
        }else {
            res.setHeader('Access-Control-Allow-Origin', '*');
            res.writeHead(200);
            res.write(JSON.stringify(fb));
        }
    }
    next();
}

同样也可以用jquery来发起jsonp请求,其原理也是跟上面一样,只是对其进行了一些封装,调用起来更方便。因为是借助于<script>标签的src属性,JSONP只能发GET请求

方案2.CORS

CORS是跨域资源分享的缩写,能够彻底解决Ajax的跨域问题,同时允许任意类型的请求,需要服务器响应头中增加下面一种或几种

//*表示允许任意源的访问,也可以指定特定的源
1.Access-Control-Allow-Origin:*   
//表示跨域访问时带上cookie,需同时在ajax请求中设置`withCredentials: true`,
2.Access-Control-Allow-Credentials: true
//预检请求后响应的必须字段,返回所有支持的方法,而不单是浏览器请求的那个方
//法。这是为了避免多次"预检"请求
3.Access-Control-Allow-Methods: GET, POST, PUT
//预检请求后响应的必须字段,放入预检请求时请求所带的头
4.Access-Control-Allow-Headers:Content-Type
//允许浏览器在指定时间内,无需再发送预检请求进行协商,直接用本次协商结果即可
5.Access-Control-Max-Age: 1728000

CORS请求分为简单请求(HEAD、GET、POST)非简单请求(PUT或DELETE或Content-Type为application)
非简单请求会向发一个预检请求(preflight),请求类型为OPTION,收到预检请求的响应后再发送真正的请求,这个时候的请求与简单请求无异。

简单地说下CORS请求会携带的头信息

//必要请求头,表示当前源,相应的预检响应需要返回Access-Control-Allow-Origin
1.Origin
//预检时会带上的头,表示真正请求的方法,相应的预检响应需要返回Access-Control-Allow-Method
2.Access-Control-Request-Method
//预检时会带上的头,表示真正请求会额外发送的头信息,相应的预检响应需要返回Access-Control-Allow-Headers
3.Access-Control-Request-Headers

示例:

//1.简单请求
//前端
var cors = function() {
    $.ajax(
        {
            url: 'http://localhost:8080/getusermsg',
            type: 'GET',
            dataType: 'json',
            success: function(userdata) {
                alert('姓名:' + userdata.data.username);
                alert('年龄:'+ userdata.data.userAge);
                alert('性别:' + userdata.data.userSex);
            },
            error: function(error) {
                alert(JSON.stringify(error));
            }
        }
    )
}
//服务端
function getusermsg(req, res, next) {
    if(req.url.match(api.getusermsg)) {
        var queryJson = queryParse(req.url.split('?')[1]); 
        var fb = {code: 1000, data: {username: 'carl', userAge: 20, userSex: '男'}};
        if(queryJson.callback) {
            res.writeHead(200);
            res.write(queryJson.callback + '(' + JSON.stringify(fb) + ')');
        }else {
            //允许任意源访问
            res.setHeader('Access-Control-Allow-Origin', '*');
            res.writeHead(200);
            res.write(JSON.stringify(fb));
        }
    }
    next();
}

//2.非简单请求
//前端
var postUser = function() {
    $.ajax({
        url: 'http://localhost:8080/postusermsg',
        type: 'POST',
        //发送json数据触发预检preflight,option请求
        contentType: 'application/json',  
        data: {username: 'jjp', userAge: 22, userSex: '男'},
        dataType: 'json',
        success: function(data) {
            if(data.code === 1000) {
                alert('添加用户成功')
            }
        },
        error: function(err) {
            alert(JSON.stringify(err));
        }
    })
}

//服务端
function postusermsg(req, res, next) {
    if(req.url.match(api.postusermsg)) {
        var body = '';
        req.on('data', function(chunk){    
            body += chunk;
        });
        req.on('end', function(){    
            var queryJson = queryParse(body);
            console.log(queryJson);
            //针对预检请求发送的头为响应设置相对应的头
            res.setHeader('Access-Control-Allow-Origin', '*');
            res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
            res.setHeader('Access-Control-Allow-Method', 'GET,POST,PUT');
            res.writeHead(200);
            res.write(JSON.stringify({code:1000, data:{}}));
            next();
        });
    }else {
        next();
    }
}

方案3.WebSocket

WebSocket是一种新的通信协议,能够在一个持久连接上提供全双工、双向通信。使用url模式也略有不同。未加密连接使用ws://,加密连接使用wss://,最重要的一点是该协议不实行同源策略。服务器需要自己确定请求源是否在白名单内,从而过滤恶意的请求。

方案4.代理

1.正向代理:需要借助同源的代理服务器,浏览器先将请求发送给代理服务器,代理服务器接收请求并其转发给目标数据服务器,由于不同源的两个服务器的交互不遵循同源策略,所以代理服务器可以接收到目标数据服务器的响应数据,再将响应数据发送回浏览器

2.反向代理:通常可以用Nginx反向代理来实现,也是利用了服务端之间的资源交互不会有跨域限制的原理。假如现在有
domainAhttp://jjp.com:3000,部署了请求页面
domainBhttp://jjp.com:8080,部署了nginx服务器
domainChttp://jjp.com:6600,部署了资源服务器

浏览器获取了domainA的页面,然后用ajaxdomainC请求数据,必然会发生跨域。所以domainA可以让nginx做反向代理服务器,让nginx服务器假扮domainA,此时浏览器请求domainB时会得到原先部署在domainA上的页面。浏览器想请求domainC的资源时,直接向domainB发请求即可,nginx服务器会拦截浏览器的请求,重写为向domainC请求,再转发该请求,nginx拿到资源后返回给浏览器

2.Cookie的跨域

同源的页面才可以共享cookie,但是如果两个源的一级域名相同,二级域名不同,浏览器可以通过设置document.domain来共享cookie,比如有

domainA:http://gg.jjp.com/index.html
domainB:http://bb.jjp.com/index.html

现在想让domainAdomainB能互相访问对方的cookie,可以双方都设置document.domainjjp.comdomainA则能够访问到domainB设置的cookiedomainB也能访问到domainA设置的cookie.

3.跨窗口的跨域通信

iframe窗口和window.open打开的窗口若与父窗口不是同源的,都无法与创建它们的父窗口通信,无法互相访问对方的document对象。如果两个窗口一级域名相同,二级域名不同,可以通过设置document.domain解决。
但是对于完全不同源的窗口,想要进行通信,可以通过下面的方法:

1.片段识别符
2.window.name
3.window.postMessage

方案1.片段识别符

地址栏中url#后面的内容变化是不会引起页面的刷新的,这部分内容就是片段识别符,当片段识别符内容变化时,会触发hashchange事件。

因此发信息的窗口可以把信息写入接收信息窗口的片段标识符中,接收信息窗口监听hashchange事件来取得自己的片段标识符,从而来达成通信的目的。

方案2.window.name

window.name值在不同的页面(甚至不同域名)加载后依旧存在,并且值最大可以达到2MB。

示例:窗口A和窗口B不同源,现在A想拿到窗口B的消息,可以借助window.name 以及 iframe实现跨域通信

步骤:
1.窗口A在页面中动态添加一个iframe,将其src置为窗口B页面地址
2.iframe加载了窗口B的页面,窗口B将要发送的消息写入window.name中
3.由于窗口A与iframe处于不同域,因为同源策略,窗口A不能访问iframe的window.name
4.此时再让iframe加载一个与窗口A同源的页面,使窗口A与iframe属于同域
5.窗口A读取iframe的window.name,至此接收到窗口B发送的消息,完成通信

方案3.window.postMessage

window.postMessageHTML5引入的一个新的api,允许两个窗口通信,不论是否两个窗口是否同源

示例:

//发送信息的窗口:http://jjp.com
var sonWin = window.open('https://www.baidu.com','百度');
//参数:要发送的信息、接受信息的窗口的源
sonWin.postMessage('你好,百度', 'https://www.baidu.com');

//接收信息的窗口:https://www.baidu.com
//监听postMessage事件
window.addEventListener('message', function(event) {
  //event.source:发送消息的窗口
  //event.origin: 发送消息的网址
  //event.data: 消息内容
  if(event.origin === 'http://jjp.com') {
    event.source.postMessage('Got it', event.origin);
    console.log(event.data)
  }
});
4.跨源数据存储

通过window.postMessage,能够实现读写其他窗口的localStorageIndexDB
在用window.postMessage实现窗口间的通信的基础上进行

  • 写:接收其他窗口的消息时,将消息作为值其存入
  • 读:接受其他窗口的消息时,将消息作为键值取出值,并将值通过postMessage发送给其他窗口

参考文章

浏览器的同源策略
浏览器的同源策略及其规避办法

推荐阅读更多精彩内容