专栏首页星回的实验室JavaScript中的沙箱机制探秘[二]:iFrame沙箱实现方案详解

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 条评论
登录 后参与评论

相关文章

  • JavaScript中的沙箱机制探秘

    最近有需求要研究下开放给用户的自动化工具,于是就顺便整理了下沙箱的相关问题。Sandbox,中文称沙箱或者沙盘,在计算机安全中是个经常出现的名词。Sandbox...

    星回
  • 打造自己的MapReduce[二]:Hadoop连接MongoDB

    在搭建完Hadoop集群后,我们可以基于HDFS做一些离线计算。然而HDFS毕竟是基于文件的系统,所以当我们存储的数据要兼顾一些线上业务访问的时候(如接入层/推...

    星回
  • Angularjs的回调

    $q.reject() 方法是在你捕捉异常之后,又要把这个异常在回调链中传下去时使用:

    星回
  • AOP 面向方面编程的介绍----基本概念(2)

    面向方面编程的介绍----基本概念(2) <?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:...

    田春峰-JCJC错别字检测
  • IoC在ASP.NET Web API中的应用

    控制反转(Inversion of Control,IoC),简单地说,就是应用本身不负责依赖对象的创建和维护,而交给一个外部容器来负责。这样控制权就由应用转移...

    蒋金楠
  • JavaWeb 例子 JDBC+JSP登陆注册留言板

    注册页面: <%@ page language="java" contentType="text/html; charset=UTF-8" pageEn...

    二十三年蝉
  • [设计模式]之七:命令模式

    将请求封装成一个对象,从而使你可用不同的请求对客户进行参数化;对请求排队或者请求日志,以及支持可撤销的操作。

    wOw
  • 用 Identity Server 4 来保护 Python web api

    项目的早期后台源码: https://github.com/solenovex/asp.net-core-2.0-web-api-boilerplate 下面开...

    企鹅号小编
  • SpringCloud技术指南系列(四)服务注册发现之Consul服务注册

    目前服务发现的解决方案有Eureka,Consul,Zookeeper等,这三个是SpringCloud官方支持的。

    品茗IT
  • 用Eclipse开发Struts实例-G

    package com.meixin.beans; public class Guestbook { private int id; priva...

    py3study

扫码关注云+社区

领取腾讯云代金券