浏览器中的跨域

在工作中遇见的跨域问题也有好几次了,但却一直对浏览器的跨域没有很完整的认知。最近正重新学习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脚本所属文档的同源,而不是脚本自身的同源!

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

2. JSONP

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

2.1. 原理实现

我们知道,通过<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>

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

  • 先定义一个回调函数,该函数的参数就是请求返回的数据,然后将函数名称附在请求上传递给服务器,
  • 服务请根据请求的资源和回调函数名称,使用数据拼接JavaScript代码,然后返回即可
<script type="text/javascript">
    function dojsonp(res){
        console.log(res);
    }
</script>
<!--向接口指定请求的资源名和回调函数名-->
<script type="text/javascript" src="http://api.shymean.com/api?callback=dojsonp"></script>

预定义服务器的资源格式

实现服务器的相应接口

<?php

$callback_name = $_GET['callback'];

// 实际响应数据
$data = "{code:200, message: 'ok', data: [1,2,3]}";
// 拼接一段完整的JavaScript代码并返回
echo "$callback_name(" . $data  .")";

// 当浏览器接受到这段代码就相当于调用dojsonp({code:200, message: 'ok', data: [1,2,3]})了

OK,这大概就是JSONP的基本实现原理。

我们可以将其封装成一个通用的请求方法,首先需要向浏览器中插入<script>标签,并设置其src属性为接口路径,加载完成并执行相关回调后,需要清理插入的标签。相关代码已放在github上。

window.getJSONP = (() => {
  let id = 0
  return (url, callback) => {
    // 注册全局函数
    let name = `_jsonp_callback_${++id}`
    window[name] = callback

    url += (url.indexOf('?') > -1 ? '&' : '?') + `callback=${name}`

    // 插入脚本
    let script = document.createElement('script')
    script.onload = function () {
      script.parentNode.removeChild(script)
      delete window[name]
    }
    script.src = url
        // 将script插入页面,发送请求
    document.body.appendChild(script)
  }
})();
// 使用
window.getJSONP(url, function(response){
  console.log(response)
})

jQuery提供了一个$.getJSON()的方法,可以用来实现JSONP形式请求。

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

2.2. 安全问题

在过去接触到的项目中,使用JSONP最常见的貌似就是各种第三方接口的服务商了。从实现可以看出,JSONP依赖于与服务器的共同约定,包括请求资源形式,回调函数名称处理等。现在来看一看JSONP可能存在的安全问题。

由于使用的是script脚本请求资源,在获得响应后浏览器也会执行响应中的文本内容,因此就可能发生XSS攻击。

以上面的例子为例,如果服务端或者响应被劫持后,浏览器实际接收到的响应内容为

some_callback_name({code:200}, console.log('mock xsssss attack'))

用户浏览器将会执行恶意代码console.log('mock xsssss attack'),此时就发生了XSS攻击。

此外,由于JSONP只支持GET请求,且相关参数基本都是固定的,因此也很容易诱导用户访问,导致CSRF。不过一般JSONP本身就是用来跨域访问,不携带相关cookie,基本上也只会请求数据,不涉及用户权限操作,因此危险程度不如XSS

3. CORS

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 详解

3.1. 相关配置

Access-Control是一系列访问控制的响应头,因此实现CORS,主要是在响应中添加相关头部

  • Access-Control-Allow-Credentials响应头表示是否可以将对请求的响应暴露给页面。返回true则可以,其他值均不可以
  • Access-Control-Allow-Methods一个逗号分隔的、表示服务器将会支持的 HTTP 请求动词(如 GET, POST)列表。
  • Access-Control-Allow-Origin限制请求域名,用来允许跨域请求
  • Access-Control-Allow-Headers
    • 格式为一个逗号分隔的列表,表示服务器将会支持的请求头部值
    • 如果使用了自定义头部(比如 x-authentication-token),则应该将其置于这个 ACA 头部
  • Access-Control-Expose-Headers
    • 跨域请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma
    • 如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定
  • Access-Control-Max-Age预请求有效期,在后面介绍预检测请求时会提到它的作用

一种方式是直接在业务服务器判断请求来源,修改相关头部并返回,参考

// 当前服务器域名加上为http2://w1.shymean.com
if($_SERVER['HTTP_ORIGIN'] == "http://w2.shymean.com"){
      // 设置Access-Control-Allow-Origin
    header('Access-Control-Allow-Origin: http://w2.shymean.com');
    header('Content-type: application/xml');
    readfile('arunerDotNetResource.xml');
}

如果使用nginx作为代理服务器,则CORS更加简单:通过add_header指令添加Access-Control相关头部即可

map $http_origin $baseCorsHost {
    default 0;
    "~http://w2.zhe800.com" http://w2.zhe800.com;
    "~https://w2.zhe800.com" https://w2.zhe800.com;
}

server {
    listen 80;
    server_name w1.zhe800.com;

    root /webapp/w1;

    add_header Access-Control-Allow-Origin $baseCorsHost;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';

    if ($request_method = 'OPTIONS') {
        return 204;
    }
}

3.2. 预检测

在使用CORS实现跨域时,有时候会发现浏览器会先发送一次 OPTIONS 请求,这是跨域请求时浏览器发送预检请求引起的

  • 对于“简单的” GETHEADPOST 请求,如果服务器没有对其作出携带特殊 HTTP 头部的响应 -- 请求依然被发送并且数据也照样被返回,但浏览器将不允许 Javascript 访问该响应。
  • 如果发送一个非简单请求,浏览器会先进行预检(preflight):首先一个 OPTIONS 请求会被发往服务器,如果服务器没有正确响应CORS头部,则浏览器就不会发送真实的非简单请求了,非简单请求包括

如果服务端接收了一个跨域的请求,进行了相关计算,后来又被浏览器的同源策略给忽略掉了,那么就会造成服务端的浪费。由于大部分服务器可能压根没想过接受跨域请求,预检请求主要是在真实请求之前检测服务器是否支持CORS,从而节省服务器的计算力,更多细节可以移步:CORS 为什么要区分『简单请求』和『预检请求』?

预检机制会导致一次业务请求会发送两次网络请求,那么如何节省掉 OPTIONS 请求来提升浏览器的请求性能呢?可以使用下面几种方式:

  • 将请求修改为简单请求,避免浏览器自动预检测,需要修改相关代码实现
  • 服务器端响应头设置 Access-Control-Max-Age 字段,那么当第一次请求该URL时会发出 OPTIONS 请求,浏览器会根据返回的该字段缓存该请求的OPTIONS预检请求的响应结果(具体缓存时间还取决于浏览器的支持的默认最大值,取两者最小值,一般为 10分钟)。在缓存有效期内,该资源的请求(URL和header字段都相同的情况下)不会再触发预检。
    • 注意 Access-Control-Max-Age预检测的缓存只针对这一个请求 URL 和相同的 header
  • 或者干脆不使用跨域了,通过nginx等方式在服务端进行中转

参考:科普一下 CORS 以及如何节省一次 OPTIONS 请求

4. 其他跨域方式

4.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元素。

4.2. 服务器中转或代理

服务器中转数据

如果将浏览器的请求url传递给同源的服务器接口,再由服务服务器去拉取请求数据,最后返回给浏览器数据,这样就可以避开浏览器的同源限制,因为浏览器请求的只是一个普通的同源接口,并没有违背同源策略。

如常见的RPC服务,web后台实际上是去真实的数据服务器拉取数据,然后返回给前端而已。

反向代理

另外通过反向代理,我们也可以绕过同源策略了。因为我们的所有请求都是发送到反向代理服务器中,然后由反向代理服务器去请求目标服务器。这跟服务器中转有点类似,不过这并不需要我们修改业务代码

# nginx下的代理配置
location / {
    proxy_pass http://localhost:8080;
}

这种通过服务器中转处理请求的方法,需要多发送一条请求报文和一条响应报文,且源服务器也客串了客户端的角色,代理服务器也会存在缓存和性能损耗的一些问题。

4.3. postMessage实现跨源通信

参考:postMessage - MDN

postMessage可以通过绑定window的message事件来监听发送跨文档消息传输内容。使用postMessage实现跨域的话原理就类似于jsonp,动态插入iframe标签,再从iframe里面拿回数据

在当前文档中监听message事件,然后请求目标文档

window.addEventListener("message", function (event) {
  // 一般需要判断是否为受信任的来源
  if (event.origin === 'http://localhost:8888') {
    const { data } = event
    console.log(data)
  }
}, false);
// 假设当前资源路径http://localhost:9999,请求跨域资源
getMessage('http://localhost:8888/postmessage.php')

function getMessage(url) {
  var iframe = document.createElement('iframe')
  iframe.src = url 
  iframe.onload = function () {
    // 虽然iframe可以加载成功,但是由于同源策略限制,我们无法直接获取iframe中的数据,因此需要在iframe中通过postMessage主动发送消息才行
    // console.log(iframe.contentWindow.document.body)
    iframe.parentNode.removeChild(iframe)
  }
  iframe.style.display = 'none'
  document.body.appendChild(iframe)
}

在目标文档中通过postMessage返回数据

// 服务端渲染的html文档,数据可以直接从后端埋入
var data = {code: 200, message: 'msg from backend'}
// 向源窗口发送数据
window.parent && window.parent.postMessage(JSON.stringify(data), 'http://localhost:9999');

可见与JSONP的使用十分相似。postMessage主要用在两个窗口之间的通信,跨域应该只是该机制能够实现的一个功能而已。

4.4. Webscoekt

参考:Websocket - MDN

作为一项新技术,WebSockets旨在从一开始就支持跨域场景。任何编写服务器逻辑的人都应该意识到跨域请求的可能性,并执行必要的验证,而无需使用浏览器端强大的同源策略。

websocket的请求报文中,有一个字段是Origin,表示该请求的请求源(origin),即发自哪个域名。因为有了Origin这个字段,所以浏览器没有对websocket执行同源策略显示,因为服务器可以根据这个字段,判断是否许可本次通信。

因此在某些场景下,我们也可以使用Websocket来实现跨域请求。

5. 小结

拖到今天,终于把浏览器的跨域问题给理顺了,起码拖了三个月。前两周学习HTTP逐渐感受到“你知道的越多,才发现你不知道的越多”这种感觉,真是好可怕。

最近正在看《Web前端黑客技术揭秘》,待我学成屠龙之术再回来把这里的坑完善了。