首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JavaScript中的沙箱机制探秘[二]:iFrame沙箱实现方案详解

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

作者头像
星回
发布2018-08-02 15:22:44
4.3K0
发布2018-08-02 15:22:44
举报

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

jsFiddle实例研究

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

jsFiddle
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请求:

network
network

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

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

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

response
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

同理,在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,如图所示:

demo
demo

总结

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

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • jsFiddle实例研究
  • sandbox的通信
    • location hash
      • window.name
        • 一些新技术
        • 基于iframe sandbox的跨平台app运行环境的实现尝试
          • 搭建Host服务器
            • 搭建沙箱服务器
              • 封装请求方法
                • Host处理请求转发
                  • 运行结果
                  • 总结
                  领券
                  问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档