在上一篇文中,我们接触了JavaScript中的sandbox的概念,并且就现阶段的一些实现思路做了总结,包括YUI的闭包、iframe的sandbox以及Nodejs的VM和child_process模块,在文中我们也知道了各自实现的局限性。而对于前端来说,让前端的第三方js代码能够从本质上产生隔离,并且让后端参与部分安全管控是最理想的状态。在这些方案中,在引擎层面制造隔离的iframe方案显然是最简单可行的。
前文中我们只是概述了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请求:
从请求头中我们可以看到几个表单的主要字段:
表单提交后的response内容如下图:
呈现结果的页面非常简单,主要由如下几个部分拼接而成:
<head>
中<script>
加载用户指定的依赖库; </body>
标签之前); <body>
中用户输入的html代码。因此我们可以猜测,表单提交后,后台对用户提交的依赖库、html、css和js代码按顺序进行了拼接并返回结果(当然还有一系列安全措施如CSRF Token的处理等),剩余的一切(包括加载外部js、执行用户提交的js代码等)交由iframe照常处理便可。
最后,执行第三方输入的iframe和host不在一个域触发了浏览器的跨域机制,避免了很多风险,然而仍然存在一些潜在风险,如iframe里的内容还是可以navigate到不同的站点,并且自动运行一些plugin或者视频,为用户制造麻烦。HTML5带来的iframe的sandbox属性为iframe的安全机制提供了规范,在添加了sandbox属性后,默认将禁止iframe中的内容执行脚本、提交表单、访问本地文件、运行插件、导航等各种风险行为。然而作为为第三方开放的线上环境,若是全封闭也就没有什么好吸引用户的了。我们来看看jsFiddle都放开了哪些权限:
我们可以试试在sandbox不开放权限的情况下会发生什么。我们删掉sandbox属性中的allow-popups,执行window.open('http://www.taobao.com');
,返回如下结果:
同理,在sandbox没有允许的情况下做其他的潜在风险行为也会抛出异常。我们可以根据需求调节sandbox开放的尺度,需要注意的是,若不是完全信任iframe中内容的话最好不要添加allow-top-navigation,这将允许当前页面被包含页面给替换,对用户造成很大误导从而引发安全问题。
在jsFiddle的例子中,他们采用提交表单的方式在iframe直接执行返回结果。然而在第三方开发平台上,用户需要有更多的权限,并且涉及到一些服务器端JavaScript的开发,这将不可避免地对后台产生潜在的影响,对同时运行在一个服务器上的其他应用产生干扰。因此出于安全方面的考量,我们需要Host以一个Proxy的身份处理sandbox中的请求。
现在,我们把沙箱运行的服务器和主站服务器(Host)放在不同的域下,由于跨域文档的隔离,Host与沙箱内部环境之间无法直接操作文档流,当沙箱内部需要向外发送HTTP请求或者从Host处获取用户信息时,我们便需要一套通信机制来解决问题。我在前一篇文章中提到了postMessage API的方法,这也是现代浏览器的不二选择,之后我们会对这种方案做进一步的封装。然而在一些情况下我们需要考虑向下兼容,在不同的窗体下由于文档流的隔离,可共享的东西并不多,这其中就包括url和window,通信方案也自然是从这上面做文章。首先我们看看兼容老版本浏览器的一些方案:
由于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存在大小限制,还可能在有些浏览器中产生历史记录,因此这并不是一个很实用的方案。
相比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作了个较好的封装,大大简化了其操作,有兴趣的同学可以看看。
目前很多大公司的产品都在施行开放化,如openAPI的改造,争取吸引更多的开发者参与到应用的生产中来,以期形成一个较为完善的生态圈。因此,提供一个方便用户发布和部署应用的工具是很必要的,这个工具需要管理用户的应用集,可以集中地为用户的应用提供授权,并且需要防止用户的应用做出越权行为,或者互相干扰冲突。同时,web服务又是一种很好的跨平台方式,所以前文总结的iframe sandbox便是一种很适合该场景的方案。笔者做了一些尝试,实现了一个iframe sandbox的简单demo。实现思路如下:
首先我们需要一台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的前端首先要对发送过来的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,之后我也会继续完善这套方案。