深入跨域 - 解决方案

京东云开发者
• 阅读 257

1 前言

前文 《深入跨域 - 从初识到入门》 中,大家已经对同源与跨域的产生历史与重要性等有了一个初步的了解了,那么我们应该如何解决在日常开发中遇到的跨域引起的问题呢?



2 一览图

我们将日常开发中的跨域解决方案大体分为两类:iframe跨域 与 API跨域:





深入跨域 - 解决方案







深入跨域 - 解决方案





3 iframe跨域

3.1 otherWindow.postMessage

otherWindow.postMessage(message,targetOrigin) 方法是 HTML5 引进的特性,可以使用它来向其它的 window 对象发送消息,无论这个 window 对象是属于同源或不同源,使用起来也比较简单。

调用 postMessage 方法的 window 对象是指要接收消息的那一个 window 对象,该方法的第一个参数 message 为要发送的消息,类型只能为字符串;第二个参数 targetOrigin 用来限定接收消息的那个 window 对象所在的域,如果不想限定域,可以使用通配符 *。

需要接收消息的 window 对象,可以通过监听自身的 message 事件来获取传过来的消息,消息内容储存在该事件对象的 data 属性中。



深入跨域 - 解决方案





A页面向B页面发送信息:

发送信息 - 页面 http://a.com/index.html 的代码:

<iframe src="http://b.com/index.html" id="myIframe" onload="test()" style="display: none;" />
<script>
  // 1. iframe载入 http://b.com/index.html 页面后会执行该函数
  function test() {
    // 2. 获取 http://b.com/index.html 页面的 window 对象,
    // 然后通过 postMessage 向 http://b.com/index.html 页面发送消息
    var iframe = document.getElementById('myIframe');
    var win = iframe.contentWindow;
    win.postMessage('我是来自 http://a.com/index.html 页面的消息', '*');
  }
</script>

接收信息 - 页面 http://b.com/index.html 的代码:

<script type="text/javascript">
  // 注册 message 事件用来接收消息
  window.onmessage = function(e) {
    e = e || event; // 获取事件对象
    console.log(e.data); // 通过 data 属性得到发送来的消息
  }
</script>

•data:从其他 window 中传递过来的对象。

•origin:调用 postMessage 时消息发送方窗口的origin源。这个字符串由 协议 + :// + 域名 + : 端口号 拼接而成,例如“https://a.com (隐含端口 80)”。

•source:对发送消息的窗口对象的引用。可以使用此来在具有不同 origin 的两个窗口之间建立双向通信。



3.2 document.domain

document.domain可用于获取及配置document文档的原始域,此方式只能用于二级域名相同的情况下

反面举例:

a.com的一个网页(a.html)里面 利用iframe引入了一个b.com里的一个网页(b.html)。

这时在a.html里面可以看到b.html里的内容,但是却不能利用javascript来操作它。因为这两个页面属于不同的域,在操作之前,浏览器会检测两个页面的域是否相等,如果相等,就允许其操作,如果不相等,就会拒绝操作。

这时,当我们利用document.domain把a.com与b.com改成同一个域时,会报"参数无效错误",因为它们的二级域名不相同。

而当我们尝试设置为com时,会报错:“com是顶级域名”。

所以需要注意:document.domain 实现跨域的方法存在局限性:需要保证二级域名、协议、端口一致才可以。



正面举例:



深入跨域 - 解决方案





比如,有一个页面,它的地址是 http://a.test.com/index.html,在这个页面里面有一个 iframe,它的 src 是 http://b.test.com/index.html。很显然,这个页面与它里面的 iframe 框架是不同域的,所以我们是无法通过在页面中书写 js 代码来获取 iframe 中的东西的。

这个时候,document.domain 就可以派上用场了,我们只要在 http://a.test.com/index.htmlhttp://b.test.com/index.html 这两个页面的index.html中都加入:

代码如下:
document.domain = "test.com";

把document.domain 都设成相同的域名就可以了。



下面我们来看一下使用本方法需要注意的两个点:

•正如我们在上面的反面举例中强调的,document.domain 的设置是有限制的,我们只能把 document.domain 设置成自身或更高一级的父域,且二级域名必须相同。例如:a.test.com 中某个文档的 document.domain 可以设成 a.test.com、test.com 中的任意一个,但是不可以设成 b.test.com,也不可以设成 baidu.com。

•另外,可能有人疑惑为什么两个页面都需要设置 document.domain 呢?因为设置document.domain的同时,会把端口重置为null,因此如果只设置一个页面的document.domain,会导致两个网址的端口不同,还是达不到同源的目的。



潜在的安全隐患:

2022年1月11日,Chrome developer 博客发布了这么一篇文章



深入跨域 - 解决方案





大致意思是,Chrome未来将禁用修改document.domain。如果你的网站依赖于设置document.domain 来解决跨域的问题,那么你可能需要注意了。



而禁止修改的原因主要是:这个改变放宽了同源策略,使父页面可以访问 iframe 的文档并遍历 DOM 树,反之亦然。而这种行为引入了极大的安全隐患,主要是针对具有不同子域的共享托管服务。document.domain的设置放开了对同一服务托管的所有其他站点的访问,这使攻击者更容易访问您的站点,因为document.domain忽略了域的端口号部分。



3.3 window.location.hash

这是一个比较奇特的方法,比如有一个这样的url:http://a.com#hello,那么我们通过执行location.hash就可以得到这样的一个字符串#hello,同时改变hash页面是不会刷新的。

假如现在我们有A页面在a.com,B页面在b.com,服务端运行地址为b.com。我们在A页面中通过iframe嵌套B页面。

•从A页面要传数据到B页面

我们在A页面中通过,修改iframe的src的方法来修改hash的内容。然后在B页面中添加setInterval定时器或者hashchange事件来监听我们的hash是否改变,如果改变那么就执行相应的操作。比如向后台服务器提交数据或者上传图片这些。

此时,B页面域名为b.com,与服务端同域,不会出现跨域问题。



•从B页面传递数据到A页面



深入跨域 - 解决方案





经过上面的方法,我们已经从服务端拿到了数据,那么如何发送给A页面呢?肯定有同学在想,从B页面向A页面发送数据就是修改A页面的hash值了。对没错,方法就是这样,但是在执行的时候会出现一些问题。我们在B页面中直接:

parent.location.hash = "#xxxx"

这样是不行的,因为前面提到过的同源策略不能直接修改父级的hash值,所以这里采用了一个代理页面的方法。部分代码:



在b.com域名下的index.html:

try {
  parent.location.hash = 'hello';
} catch (e) {
  // ie、chrome的同源安全机制无法修改parent.location.hash,
  // 所以要利用一个中间的a.com域下的代理iframe修改location.hash
  // 如 A => B => C,其中,当前页面为B,AC在同一域名下
  // B不能直接修改A的hash值,故修改C,然后由C修改A
  const ifrproxy = document.createElement('iframe');
  ifrproxy.style.display = 'none';
  // 注意该文件在"a.com"域下
  ifrproxy.src = 'http://a.com/c.html#hello';
  document.body.appendChild(ifrproxy);
}

我们可以利用try...catch...进行一个兼容。如果可以直接修改我们就直接修改,如果不能直接修改,那么我们在B页面中再添加一个iframe然后指向C页面(我们暂时叫他代理页面C,此页面和A页面是在相同的一个域下面),我们可以用同样的方法在url后面添加需要传递的信息。在代理页面中:

parent.parent.location.hash = self.location.hash.substring(1);

只需要写这样的一段js代码就完成了修改A页面的hash值。



下面,我们只需要对A页面中hash值的变化进行监听:

•可以通过添加一个setInterval事件,来监听hash值的改变

•可以通过hashchange事件监听

// http://a.com/index.html
window.onhashchange = checkMessage;


function checkMessage() {
  var message = window.location.hash;
  // ...
}

实现的核心思路就是通过修改URL的hash值,然后用定时器或hashchange事件来监听值的改变。

这种方法存在诸多的缺点,并不推荐,只是简单介绍一下:

•传递的数据会直接在URL里面显示出来,不是很安全

•传输的数据容量和类型都有限



3.4 window.name

window 对象有个 name 属性,该属性有个特征:即在一个窗口(window)的生命周期内,窗口载入的所有的页面(不管是相同域的页面还是不同域的页面)都是共享一个 window.name 的,每个页面对 window.name 都有读写的权限,window.name 是持久存在一个窗口载入过的所有页面中的,并不会因新页面的载入而进行重置。

如果name值没有修改,那么它将不会变化,并且这个值可以非常的长(2MB)。



通过下面的例子介绍如何通过 window.name 来跨域获取数据的。



深入跨域 - 解决方案





页面 http://b.com/index.html 的代码:

<script type="text/javascript">
  // 1. 给当前的 window.name 设置一个 http://a.com/index.html 页面想要得到的数据值 
  window.name = "hello world!";
</script>



页面 http://a.com/index.html 的代码:

<iframe src="http://b.com/index.html" id="myIframe" onload="test()" style="display: none;" />
<script>
  // 2. iframe载入 "http://b.com/index.html 页面后会执行该函数
  function test() {
    const iframe = document.getElementById('myIframe');

    // 重置 iframe 的 onload 事件程序,
    // 此时经过后面代码重置 src 之后,
    // http://a.com/index.html 页面与该 iframe 在同一个源了,可以相互访问了
    iframe.onload = function() {
      // 4. 获取 iframe 里的 window.name
      var data = iframe.contentWindow.name; 
      console.log(data); // hello world!
    };

    // 3. 重置一个与 http://a.com/index.html 页面同源的页面
    iframe.src = 'http://a.com/c.html';
  }
</script

方法原理:A页面通过iframe加载B页面。B页面获取完数据后,把数据赋值给window.name。然后在A页面中修改iframe使他指向本域的一个页面。这样在A页面中就可以直接通过 iframe.contentWindow.name 获取到B页面中获取到的数据。



4 API跨域

4.1 JSONP

JSONP(JSON with Padding) 是 json 的一种"使用模式",可以让网页从别的域名(网站)那获取资料,即跨域读取数据。

原理:

•在当前页面中,通过script标签加载指定资源路径,并利用这个请求路径传递数据。

•我们需要知道,对于script标签请求回来的内容,浏览器会作为脚本执行。

•所以,针对当前页面所需要的数据,跨域的服务端把数据放进当前页面本地的一个js方法里,当前页面在获取到script标签的内容后会执行此方法,在本地的js对返回的数据进行处理。这样就实现了不同域名下的两个站点间的交流。





深入跨域 - 解决方案





// http://a.com/index.html
// 1. 定义一个 回调函数 handleResponse 用来接收返回的数据
function handleResponse(data) {
console.log(data);
};


// 2. 动态创建一个 script 标签,并且告诉后端回调函数名叫 handleResponse
var body = document.getElementsByTagName('body')[0];
var script = document.createElement('script');
script.src = 'http://b.com/api?callback=handleResponse';
body.appendChild(script);


// 3. 通过 script.src 请求 `http://b.com/api?callback=handleResponse`,
// 4. 后端能够识别这样的 URL 格式并处理该请求,然后返回 handleResponse(JSON.stringify({ "data": "hello" })) 给浏览器
// 5. 前端在接收到 handleResponse({ "data": "hello" }) 之后立即执行 ,也就是执行 handleResponse 方法,获得后端返回的数据,这样就完成一次跨域请求了



服务端 - Node:

// http://b.com/api
const http = require('http');
const urllib = require('url');


const port = 80;


http.createServer(function (req, res) {
  const { query } = urllib.parse(req.url, true);
  if (query && query.callback) {
    const { callback } = query
    const data = { "data": "hello" }
    const str = `${callback}(${JSON.stringify(data)})` // 拼成callback(data)        
    res.end(str);
  } else {
    res.end(JSON.stringify('啥也没有啊你'));
  }
}).listen(port, function () {
  console.log('server is listening on port ' + port);
}



JSONP的实现流程

1.前端:

•声明一个回调函数(如handleResponse),其函数名当做参数值,要传递给跨域请求数据的服务器,函数形参为要获取的目标数据(服务器返回的data)。

•创建一个