浏览器中的跨域

在工作中遇见的跨域问题也有好几次了,但却一直对浏览器的跨域没有很完整的认知。最近正重新学习JS基础知识,这些之前学习过程中遗留下来的问题现在一个一个解决掉吧。

<!--more-->

1. 同源策略

所谓跨域问题,其根本原因是由于浏览器的同源策略所引起的。

1.1. 基础概念

浏览器中包含了JavaScript解释器,也就是说,一旦载入了页面,JavaScript程序就会在用户的电脑上执行。出于安全性考虑,浏览器必须限制JavaScript,浏览器的限制可以分为下面两个方面:

  • 禁止客户端JS实现某些功能,比如,不允许JS操作用户计算机的文件系统
  • 限制浏览器自身的某些功能,比如在使用JS打开新的窗口时先询问用户,以及限制JavaScript能够操作的Web内容等。

上面第二条限制了JavaSCript脚本不能读取从不同服务器载入的文档,除非这个就是包含该脚本的文档。关于这一点,更书面的说法称作:浏览器的同源策略

同源策略是对JavaScript代码能够操作哪些Web内容的一条完整的安全限制,具体来说,JavaScript脚本只能读取和所属文档来源相同的窗口和文档的属性。在HTTP协议的学习中了解到,文档资源由URL进行标识,其来源包括协议方案,主机和端口号,这里的“所属文档来源相同”指的是:

  • 协议相同
  • 域名相同
  • 端口号相同

1.2. 限制的意义

同源策略是浏览器对JavaScript功能的限制,目的是为了保护用户的信息安全。举个例子,当服务器为客户端设置cookie之后,浏览器再次向该服务器发送请求时,会将该cookie附在请求头发送回去,然后服务器就会识别该用户并执行相关的操作。 用户访问了某个站点A,并成功保存了cookie,cookie包含了一些用户的隐私信息,来自站点A的文档的脚本可以通过document.cookie访问服务器设置的cookie。如果另外某个网站B,可以读取直接读取A网站的Cookie,无疑是十分危险的。所以浏览器必须对客户端JavaScript的某些功能进行限制。

1.3. 限制的场景

在很多方面都可以看见同源策略的限制,不过最基本的应该是iframe中多个窗口之间的限制了。 每个窗口都是他自身的JavaScript的执行上下文,且Window作为全局对象,但是如果一个窗口被包含在另一个窗口内部,在父窗口操作子窗口看上去应该是一件理所应当的事情,一个常见的应用场景是:在某些后台管理系统中,通过侧边栏导航控制主内容区域iframes的路径来实现更好的体验效果。

<aside>
    <ul>
         <li><a href="2.html" target="main">同源页面文档</a></li>
        <li><a href="http://www.baidu.com" target="main">非同源页面</a></li>
    </ul>
</aside>
<main>
     <iframe src="2.html" name="main"></iframe>
</main>

考虑这种需求,子窗口内的某个按钮点击,需要触发全屏的阴影遮罩并弹出警告,弹出层通常是通过向文档中插入样式节点来实现的(常用的比如layer.js插件)。为了达到全屏效果,必须向顶层窗口插入节点,但是事件监听又是在具体的子文档页面上注册的,这代表着相关的操作是在子窗口进行的。所幸,浏览器为我们在窗口之间提供了parenttop等属性,用于在子窗口中操作父窗口。

那么问题来了,如果允许子窗口任意操作父窗口的元素,则可能发生一些很危险的事情,比如控制父窗口表单的提交。如果是其他来源的子窗口,放任这样的做法肯定是不合适的。此时,浏览器的同源策略就很有用了,根据同源策略的限制,子窗口的JavaScript脚本根本无法操作当前文档父窗口的元素,因为当前文档与符文的来源不同。

// 在2.html中操作父窗口的表单提交
// 表单提交是不受同源策略控制的!
btn.onclick = function(){
    parent.tform.submit();
}

实际上,上面的事件监听也可以在父窗口进行注册

main.onload = function(){
    // 通过iframe的name值可以直接获取到对应window对象
    var btn = main.document.getElementById("btn");
    btn.onclick = function(){
        console.log(1);
    }
}

但是,如果没有同源限制,这个问题就更大了。这样完全可以通过全屏的iframe引入一个其他源的网站(比如银行登陆页面啥的),通过样式伪造成真正的登陆页面,然后在当前页面监听子窗口(实际的登陆页面),窃取用户信息啥的。幸好,浏览器的同源策略保证了这些问题不会发生。

此外,比如canvas中的toDataURL,Ajax中的请求路径等,都能看见同源策略的身影。

关于同源策略,我还发现了网上有另外一种说法:

同源策略的本质是一种约定,可以说web的行为就是构建在这种约定之上的。与其说浏览器"指定"了同源策略,不如说是浏览器"实现"了同源策略。就好比我们人类的行为必须受到法律的约束一样,同源策略的目的就是限制不同源的document或者脚本之间的相互访问,以免造成干扰和混乱。

1.4. 限制的对象

最后,需要理解的是,脚本自身的来源(脚本可以是通过src指定的任意URL来源)与同源策略并没有关系,同源策略指的是,文档中的Javascript脚本,能够操作的文档包括

  • 当前文档(这是显而易见的),
  • 与当前文档页面同源的其他文档,指某个iframe标签引入的同源文档页面,对于非同源的文档页面,无法使用frames或者parent等属性获取文档对象并操作

总之,一定要理解同源策略里面的“同源”,是JS脚本所属文档的同源,而不是脚本自身的同源!

2. 跨域请求

浏览器处于安全性的考虑,对JavsScript做了同源限制,但是在某些时候,这些限制却过于严格了。下面整理一些常用的跨域技术,有的是在项目中已经使用过的了,有的是查资料的时候顺手补齐的,并没有经过实践。

2.1. 多个子域名之间的跨域

同源策略给那些使用多个子域名的大站点带来了一些问题,在某些时候一个子域名的文档可能需要访问另一个子域名的文档属性,但是同源策略要求URL域名完全相同。 针对这个问题,可以使用document.domain属性解决这个问题:域名不同,通过JavaScript设置成相同不就可以了嘛。

// 默认情况下,domain属性存放的是载入文档的服务器的域名
var d = document.domain; // foo.shymean.com

// 将服务器域名设置为将要操作的文档来源的域名
document.domain = "shymean.com";

// 如果两个窗口包含的脚本把domain设置成了相同的值,而且所用的协议,端口一致,就那么这两个窗口就不受同源策略的限制
// ...

当然,这种修改也是有限制的,修改的域名必须具有有效的域前缀或它本身(即与修改前包含相同的基础域名,这里是shymean.com),此外,domain的值中必须有一个点号,不能把它设置为com或其他顶级域名。 通过修改domain属性来实现跨域,最常用的场景应该是操作iframe中的DOM元素。

2.2. 跨域资源共享

W3C新增了一个叫做"跨域资源共享"(Cross-origin resource sharing,简称CORS)的标准,专门用于解决跨域请求的问题。 CORS使用Origin请求头和Access-Control-Allow-Origin响应头来扩展HTTP首部行,因此需要浏览器和服务器同时支持:

  • 请求资源的服务器预先使用头信息显式地列出源,或者使用通配符来匹配所有的源并允许任何地址请求文件
  • 当浏览器发现某个请求是跨域时(最常见的情形应该是Ajax请求),会自动在请求报文首部添加Origin等附加信息。
  • 如果Origin指定的源不再许可范围内,则响应报文不会包含Access-Control-Allow-Origin,此时浏览器会抛出请求失败的错误
  • 如果请求成功,Access-Control-Allow-Origin会返回请求时Origin字段的值,或者是一个*

可见,实现CORS的关键在于服务器的配置。使用这种技术,确实不用再受同源策略的影响了,并且也不需要我们在前端做任何处理,除非需要在XMLHttpRequest中发送cookie,则需要在配置xhr对象的withCredentials属性为true(这种情况比较少见)。

关于跨域资源共享的更多了解,请移步跨域资源共享 CORS 详解。服务器配置这块,我也不了解,还得进一步学习才行。

2.3. JSONP

在前面的同源策略中提到,<script>标签的src属性是不受同源限制的,因此,可以使用加载脚本的方式从其他服务器请求数据,这种使用<script>作为Ajax传输的技术称为JSONP。

我们知道,通过<script>脚本引入的,实际上是一段可执行的JavaScript代码。试想,如果这段代码新建了一个全局变量,然后将请求数据赋值给改变量,当这段代码执行完毕,后面的代码就可以通过这个变量使用请求的数据了,这么做完全避开了同源策略。不过由于是通过脚本的src属性来加载资源的,这种方式决定了JSONP只能用于GET请求。

// www.some_other.com/data.js
var aGlobalData = {}; // 将请求数据赋值给这个全局变量

// 本地文档
// 如果是事件触发的,则需要先生成这个dom节点并插入达到文档中
<script src="www.some_other.com/data.js"></script>
<script>
    // 使用aGlobalData做一些事,当然,我们需要知道请求脚本返回的这个变量名
</script>

实际上,更通用的做法不是将请求的数据赋值给一个全局变量,而是在请求的脚本中执行一个函数(函数名称由请求参数指定),并将请求数据通过参数传递到函数中。具体的步骤是

  • 先定义一个回调函数,该函数的参数就是请求返回的数据,然后将函数名称附在请求上传递给服务器,
  • 服务请根据请求的资源和回调函数名称,
<script type="text/javascript">
    function dojsonp(res){
        console.log(res);
    }
</script>
// 向接口指定请求的资源名和回调函数名
<script type="text/javascript" src="http://www.test.com/api?resource=data&cb=dojsonp"></script>

预定义服务器的资源格式

// data.js
// 将数据作为参数
({
    name: 'jsonp test',
    price: '100.00'
});

实现服务器的相应接口

// 获取请求数据
$resource = $_REQUEST['resource'];
$cb = $_REQUEST['cb'];

// 加载文件内容并添加函数名称,拼接一段完整的JavaScript代码
$data = $cb.ltrim(file_get_contents($resource.'.js'));

// 返回数据,当浏览器接受到这段代码就相当于调用dojsonp函数
exit($data);

OK,这大概就是JSONP的基本实现原理。如果是异步请求(通常也基本都异步的场景使用JSONP),则需要向浏览器中插入<script>标签,剩下的实际上都是一样的,最后需要清理生成的脚本(这个函数已经执行完毕,且只会执行一次,即使不清除应该也没有问题吧)。

<button id="t">click</button>
<script type="text/javascript">
    function dojsonp(res){
        console.log(res);
    }
    function createJSONP(url){
        var script = document.createElement("script");
        script.src = url;

        return script;
    }

    t.onclick = function(){
        var url = "http://www.test.com?resource=data&cb=dojsonp";
        var script = createJSONP(url);

        document.body.appendChild(script);
    }
</script>

这里可以把同时把请求和生成脚本节点进一步封装,由于这里只是了解JSONP的原理,这里就不折腾了。jQuery提供了一个$.getJSON()的方法,可以用来实现JSONP形式请求。

从实现可以看出,JSONP依赖于与服务器的共同约定,包括请求资源形式,回调函数名称处理等。因此适合与信任的站点进行通信,使用JSONP最常见的貌似就是各种接口的服务商了(之前有个项目是查汽车违章,后来发现就几个省的数据可以用,真是蛋疼啊)。

最后,关于JSONP的知识,如果这里解释的不够清楚,可以移步这里

2.4. 服务器中转

开头就提到,跨域问题主要是由于浏览器的同源策略限制所引起的。如果将浏览器的请求url传递给同源的服务器接口,再由服务器去拉取请求数据,最后返回给浏览器数据,这样同样可以避开浏览器的同源限制(因为客户端请求的只是一个普通的同源接口)。 这种通过服务器中转处理请求的方法,需要多发送一条请求报文和一条响应报文,且源服务器也客串了客户端的角色,在效率上会有一些影响。如果没有更好的解决办法,可以试一试这种办法。

3. 小结

拖到今天,终于把浏览器的跨域问题给理顺了,起码拖了三个月。前两周学习HTTP逐渐感受到“你知道的越多,才发现你不知道的越多”这种感觉,真是好可怕。最近正在看《Web前端黑客技术揭秘》,待我学成屠龙之术再回来把这里的坑完善了。

《同构JavaScript应用开发》读书笔记 HTTP协议之用户识别(四)