JavaScript中的沙箱机制探秘[二]:iFrame沙箱实现方案详解

在上一篇文中,我们接触了JavaScript中的sandbox的概念,并且就现阶段的一些实现思路做了总结,包括YUI的闭包、iframe的sandbox以及Nodejs的VM和child_process模块,在文中我们也知道了各自实现的局限性。而对于前端来说,让前端的第三方js代码能够从本质上产生隔离,并且让后端参与部分安全管控是最理想的状态。在这些方案中,在引擎层面制造隔离的iframe方案显然是最简单可行的。

jsFiddle实例研究

前文中我们只是概述了iframe沙箱的基本原理并且提供了一种简单的实现方式,在本篇中,我们将结合jsFiddle的实例探讨更详细的实现方案。

jsFiddle主页面如上图,我们输入了一段html代码、css样式和一段js代码,在result框里输出了执行结果,弹出了alert框。那么这个流程是怎么实现的呢?

首先让我们从页面的代码入手。可以看到,主页面的结构大致如下:

<form method="post" id="show-result" target="result" action="//fiddle.jshell.net/_display/">
    <!-- header items -->
    <a class="aiButton" id="run" title="Run (CTRL + Return)" href="#run"><span class="icon-caret-right"></span>Run</a>
    <!-- header items END -->

    <!-- content -->
    <textarea id="id_code_html" name="code_html"></textarea>
    <textarea id="id_code_js" name="code_js"></textarea>
    <textarea id="id_code_css" name="code_css"></textarea>

    <iframe name="result" sandbox="allow-forms allow-popups allow-scripts allow-same-origin"></iframe>
    <!-- content END -->
</form>

Run按钮上绑定了一个提交表单的动作,并且表单target指向iframe。iframe将载入POST请求返回的结果页面。

接着我们再分析提交表单的HTTP请求:

从请求头中我们可以看到几个表单的主要字段:

  • js_lib为用户指定装载的js库id;
  • addexternalresources为用户指定的外部资源链接;
  • code_html为用户输入的经过base64编码过的html代码;
  • code_js为用户输入的经过base64编码过的js代码;
  • code_css为用户输入的经过base64编码过的css样式。

表单提交后的response内容如下图:

呈现结果的页面非常简单,主要由如下几个部分拼接而成:

  1. <head><script>加载用户指定的依赖库;
  2. 内联样式表中拼接用户输入的css样式;
  3. 内嵌用户输入的js代码(根据用户的选择位于onload/domReady函数体内或者</body>标签之前);
  4. <body>中用户输入的html代码。

因此我们可以猜测,表单提交后,后台对用户提交的依赖库、html、css和js代码按顺序进行了拼接并返回结果(当然还有一系列安全措施如CSRF Token的处理等),剩余的一切(包括加载外部js、执行用户提交的js代码等)交由iframe照常处理便可。

最后,执行第三方输入的iframe和host不在一个域触发了浏览器的跨域机制,避免了很多风险,然而仍然存在一些潜在风险,如iframe里的内容还是可以navigate到不同的站点,并且自动运行一些plugin或者视频,为用户制造麻烦。HTML5带来的iframe的sandbox属性为iframe的安全机制提供了规范,在添加了sandbox属性后,默认将禁止iframe中的内容执行脚本、提交表单、访问本地文件、运行插件、导航等各种风险行为。然而作为为第三方开放的线上环境,若是全封闭也就没有什么好吸引用户的了。我们来看看jsFiddle都放开了哪些权限:

  • allow-forms: 允许iframe中的内容提交表单;
  • allow-popups: 允许弹出内容,包括如window.open(), showModalDialog(),target="_blank"等;
  • allow-scripts: 允许iframe中执行js代码;
  • allow-same-origin: 允许iframe中的文档包括自己的源。这意味着sandbox中的内容可以访问origin的cookie或其他存储中的数据。(若这一项禁用,那么iframe中的文档也不包含自己的源,即无法访问任何存储数据)

我们可以试试在sandbox不开放权限的情况下会发生什么。我们删掉sandbox属性中的allow-popups,执行window.open('http://www.taobao.com');,返回如下结果:

同理,在sandbox没有允许的情况下做其他的潜在风险行为也会抛出异常。我们可以根据需求调节sandbox开放的尺度,需要注意的是,若不是完全信任iframe中内容的话最好不要添加allow-top-navigation,这将允许当前页面被包含页面给替换,对用户造成很大误导从而引发安全问题。

sandbox的通信

在jsFiddle的例子中,他们采用提交表单的方式在iframe直接执行返回结果。然而在第三方开发平台上,用户需要有更多的权限,并且涉及到一些服务器端JavaScript的开发,这将不可避免地对后台产生潜在的影响,对同时运行在一个服务器上的其他应用产生干扰。因此出于安全方面的考量,我们需要Host以一个Proxy的身份处理sandbox中的请求。

现在,我们把沙箱运行的服务器和主站服务器(Host)放在不同的域下,由于跨域文档的隔离,Host与沙箱内部环境之间无法直接操作文档流,当沙箱内部需要向外发送HTTP请求或者从Host处获取用户信息时,我们便需要一套通信机制来解决问题。我在前一篇文章中提到了postMessage API的方法,这也是现代浏览器的不二选择,之后我们会对这种方案做进一步的封装。然而在一些情况下我们需要考虑向下兼容,在不同的窗体下由于文档流的隔离,可共享的东西并不多,这其中就包括url和window,通信方案也自然是从这上面做文章。首先我们看看兼容老版本浏览器的一些方案:

location hash

由于Host可更改iframe的src,并且以hash的方式加在url的尾部并不会造成页面跳转,而在iframe内部可以通过location.hash的值来获取来自Host的信息。举一个简单的demo:

<!-- Host html page -->
<iframe id="sandboxFrame" src="http://www.a.com/b" name="sandboxFrame" sandbox=""></iframe>

当Host需要向sandbox中传递消息时,就在iframe的src尾部添加hashTag:

document.getElementById('sandboxFrame').src = "http://www.a.com/b#data";

在iframe内部的页面轮询location的变化,并获取hashTag即可:

setInterval(function() {
    var data = window.location.hash.substr(1);
}, 1000);

那么怎样从sandbox中向Host发送消息呢?我们可以在iframe中再套一个与Host同源的iframe作为Proxy,同样采用location hash的方法将消息传送到Proxy中。对于Proxy,由于与Host同源,便可通过window.top定位到窗口之后直接调用Host内部的方法了。这样的方法虽然简便可行,然而将消息直接添加到url里进行传送并不是一个安全的方法,并且url存在大小限制,还可能在有些浏览器中产生历史记录,因此这并不是一个很实用的方案。

window.name

相比location hash,window.name值最长支持2MB大小的数据,且它绑定至iframe上,即使iframe中重新加载不同页面,window.name的值也不会变,因此这个变量也被用来作跨域通信。由于跨域的iframe间无法获取window.name的值,因此我们需要加载web服务的iframe后将其导向到同源的另一处source,然后获取window.name值。简单示例如下:

var frame = document.createElement('iframe'),
    state = 0,
    data;

document.body.appendChild(frame);
frame.style.display = 'none';

var loadFn = function() {
    if (state === 1) {
        data = frame.contentWindow.name;
    } else if (state === 0) {
        state = 1;
        frame.contentWindow.location = 'same origin';
    }
};

frame.onload = loadFn;
frame.src = 'web service origin';

iframe在读取web服务页面后导航至与Host同源页面,此时第二次触发iframe的onload方法,window.name不变,而同域下Host也可取得其值,便达到了跨域传递消息的目的。关于这一方案较为成熟的实现可以参看Messenger.js

一些新技术

在现今的一些应用中,浏览器的版本也不再有那么多束缚,那么何不大胆尝试一些更好用的新鲜技术呢?websocket是HTML5标准的API,它允许跨域通信,并且有一个很大的优势就是可以保持连接状态,实现两端实时交流。websocket用起来很简单,示例如下:

var ws = new WebSocket('ws://127.0.0.1:8080/url'); //新建一个WebSocket对象,其中ws开头是普通的websocket连接,wss是安全的websocket连接,类似于https。
ws.onopen = function() {
    // 连接被打开时调用
};
ws.onerror = function(e) {
    // 在出现错误时调用
};
ws.onclose = function() {
    // 在连接被关闭时调用
};
ws.onmessage = function(msg) {
    // 在服务器端向客户端发送消息时调用
    // msg.data包含了消息
};
// 发送数据
ws.send('some data');
// 关闭套接口
ws.close();

这样不同的iframe间可以保持和同一服务器的长连接,通过转发实现交互;或者用websocket与服务器交互后再利用postMessage在窗体间进行交互。Socket.IO对websocket作了个较好的封装,大大简化了其操作,有兴趣的同学可以看看。

基于iframe sandbox的跨平台app运行环境的实现尝试

目前很多大公司的产品都在施行开放化,如openAPI的改造,争取吸引更多的开发者参与到应用的生产中来,以期形成一个较为完善的生态圈。因此,提供一个方便用户发布和部署应用的工具是很必要的,这个工具需要管理用户的应用集,可以集中地为用户的应用提供授权,并且需要防止用户的应用做出越权行为,或者互相干扰冲突。同时,web服务又是一种很好的跨平台方式,所以前文总结的iframe sandbox便是一种很适合该场景的方案。笔者做了一些尝试,实现了一个iframe sandbox的简单demo。实现思路如下:

搭建Host服务器

首先我们需要一台Host服务器提供用户信息和应用集中管理工作并呈现Host页面。后台我们利用nodejs搭建一个简单的http server,代码如下:

// iframeHost/app.js
var connect = require('connect');
var serveStatic = require('serve-static');

var app = connect();
app.use(serveStatic(__dirname));
app.listen(8081);

为了测试方便,这里我们只是用serve-static建立了一个简单的静态文件服务器,让其运行在8081端口上。

然后,我们编写一个简单的首页,这个首页包含一个iframe,用以在sandbox中载入第三方应用:

<!-- iframeHost/index.html -->
<html>
<head>
    <title>iframe - host</title>
    <link rel="stylesheet" href="./style.css"/>
</head>
<body>
    <header>
        <h1>Demo Box</h1>
    </header>

    <iframe id="pluginBox" name="pluginBox" width="100%" height="800px" sandbox="allow-scripts allow-same-origin" frameborder="0"></iframe>
</body>
</html>

搭建沙箱服务器

Host服务器搭建完成,这时我们在不同的端口上再搭建一个沙箱服务器以容纳第三方应用,nodejs代码同上。这里我clone了@已繁的openAPI test作为第三方app的测试。沙箱服务器运行在8082端口,还包括一个测试secret key接收的app。接着修改Host的首页,添加如下代码:

<!-- iframeHost/index.html -->
<div class="navbar-right">
    <ul>
        <li><a target="pluginBox" href="http://localhost:8082/private/test_api.html">aliyun OpenApi</a></li>
        <li><a target="pluginBox" href="http://localhost:8082/tool/index.html">test tool</a></li>
    </ul>
</div>

完成这部后我们便可以通过点击nav中的链接来切换iframe中加载的app了。

封装请求方法

openAPI test需要访问阿里云的web service已测试API,这需要app从iframe中传递HTTP请求信息给Host,然后Host将其发送到后台,后台包装成HTTP请求转发给阿里云web service,随后将返回的信息经由Host前端转发给iframe中的app。这一过程采用postMessage实现,并简单封装到了sandbox.js中,代码如下:

/*!
 * sandbox.js
 * Capsulate methods of forwarding HTTP requests and fetching secret key across different iframes.
 */

var sandbox = (function() {
    return {
        /**
         * Forward a request sent by apps and return data in response for them.
         * @param  {Object}   request  Params of HTTP request, like { method: 'GET', url: 'xxx', headers: {} }
         * @param  {Function} callback Callback for hosted iframe
         * @return {undefined}
         */
        sendRequest: function(request, callback) {
            window.addEventListener('message', messageHandler);
            window.top.postMessage(request, '*');

            function messageHandler(e) {
                if (e.source === window.top) {
                    var res = {};
                    res.success = true;
                    res.data = e.data;
                    callback(res);
                    window.removeEventListener('message', messageHandler);
                }
            }
        }
    };
})();

由于postMessage只是单向通信,而iframe中的app发送请求后需要用回调处理返回的结果,因此这里在postMessage之后添加了一个message事件的监听,在Host得到结果后可以通过postMessage把消息传回给app。这里只是验证了消息的源窗体,而没有验证返回消息是否匹配发送的消息,因此当消息频发时会存在问题。可以通过在消息内添加时间戳等方法来解决此问题,这一点会在之后完善。

Host处理请求转发

Host的前端首先要对发送过来的message做处理,随后将其发给后台。在Host首页添加代码如下:

<!-- iframeHost/index.html -->
window.onload = function() {
    window.addEventListener('message', messageHandler);

    function messageHandler(e) {
        document.getElementById('msgInput').innerText = JSON.stringify(e.data);
        var xhr = new XMLHttpRequest();
        var data = {
            content: e.data
        }
        xhr.onreadystatechange = handler;
        xhr.open('POST', '/forward', true);
        xhr.send(JSON.stringify(data));

        function handler() {
            if (xhr.readyState == 4) {
                var res = {};
                if (xhr.status == 200) {
                    console.log(xhr.responseText);
                    res.success = true;
                    res.data = xhr.responseText;
                    send(res);
                } else {
                    console.log("Request not received.");
                    res.success = false;
                    res.error = "Request not received.";
                    send(res);
                }
            }
        }
    }   

    function send(text) {
        var iframe = document.getElementById('pluginBox');
        iframe.contentWindow.postMessage(text, '*');
    }
}

messageHandler在接受app的消息后将其通过ajax转发给后台,后台响应后再发回给iframe中的app。同样,这里并未做更多验证,消息流的格式也需要规范化。最后,Host后台作如下处理:

// iframeHost/app.js
var http = require('http');
var url = require('url');

app.use('/forward', function(req, res) {
    var data = '';
    if (req.method == 'POST') {
        req.on('data', function(chunk) {
          data += chunk.toString();
        });

        req.on('end', function() {
            var param = (JSON.parse(data)).content;
            var parsedUrl = url.parse(param.data.url);
            var options = {
                host: parsedUrl.hostname,
                port: 80,
                path: parsedUrl.path,
                method: param.data.method,
                headers: param.headers    
            };

            var req = http.request(options, function(response) {
                response.setEncoding('utf8');
                var str = '';
                response.on('data', function(chunk) {
                    console.log('Incoming chunk: ' + chunk);
                    str += chunk;
                });
                response.on('end', function () {
                    res.writeHead(200, "OK", {'Content-Type': 'text/plain'});
                    res.end(str);
                });
            });

            req.on('error', function(e) {
                console.log("error: " + e.message);
            });

            req.end();
        });
    } else {
        res.end('Not POST');
    }   
});

运行结果

服务器运行后,通过Host首页加载openAPI test,指定好参数后请求从iframe中发出,在Host页面上显示参数,随后经由后台发往阿里云web service,再将返回结果发送给app,最后app在控制台输出log,如图所示:

总结

在本篇文章中,我们分析了jsFiddle实现沙箱的方法,以及常用的sandbox与Host间通信的方案。最后,基于开发第三方应用平台的需求,我采取了结合postMessageAPI的方案,实现了一个简单的demo,之后我也会继续完善这套方案。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏Android先生

20分钟教你使用hexo搭建github博客

备注:该教程基于Hexo 2.x版本,目前Hexo是3.x版本,照本教程实现有可能会出现404错误,笔者目前还未找时间去解决,待笔者找时间解决该问题后,再写一篇...

1222
来自专栏Fred Liang

Service Worker 实现 web 应用消息推送

Service Worker 是事件驱动的 worker,生命周期与页面无关,关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动.

6872
来自专栏逸鹏说道

Web前端性能优化教程03:网站样式和脚本&减少DNS查找、避免重定向

一、将样式表放在顶部 可视性回馈的重要性 进度指示器有三个主要优势——它们让用户知道系统没有崩溃,只是正在为他或她解决问题;它们指出了用户大概还需要等多久,以便...

42213
来自专栏FreeBuf

CVE-2017-4918:VMware Horizon的macOS客户端代码注入漏洞分析

本文我们将探讨如何通过 VMware Horizon macOS客户端版本4.4.0 (5164329)中存在的代码注入漏洞获取本地root权限。在此文发布前我...

2723
来自专栏智能大石头

[netcore]CentOS安装使用.netcore极简教程(免费提供学习服务器) 新生命团队netcore服务器免费开放计划

本文目标是指引从未使用过Linux的.Neter,如何在CentOS7上安装.Net Core环境,以及部署.Net Core应用。

1990
来自专栏散尽浮华

Linux下快速迁移海量文件的操作记录

有这么一种迁移海量文件的运维场景:由于现有网站服务器配置不够,需要做网站迁移(就是迁移到另一台高配置服务器上跑着),站点目录下有海量的小文件,大概100G左右,...

3167
来自专栏移动端开发

iOS 封装.framework 以及使用

.framework是什么? ----       .framework是什么?       这个问题相信做iOS的都知道答案。 在我们的日常开发中,经常会用到...

5686
来自专栏沈唁志

宝塔面板Mysql 5.6版本无法正常启动的解决方法

朋友找我的时候说的是 Mysql 启动不了,看他发的截图是宝塔面板,就要来了面板信息去看了一下

2.1K2
来自专栏散尽浮华

Netdata---Linux系统性能实时监控平台部署记录

通常来说,作为一个Linux的SA,很有必要掌握一个专门的系统监控工具,以便能随时了解系统资源的占用情况。下面就介绍下一款Linux性能实时监测工具-Netda...

8918
来自专栏程序工场

apache和tomcat区别

经常在用apache和tomcat等这些服务器,可是总感觉还是不清楚他们之间有什么关系,在用tomcat的时候总出现apache,总感到迷惑,到底谁是主谁是次,...

932

扫码关注云+社区

领取腾讯云代金券