最近有需求要研究下开放给用户的自动化工具,于是就顺便整理了下沙箱的相关问题。Sandbox,中文称沙箱或者沙盘,在计算机安全中是个经常出现的名词。Sandbox是一种虚拟的程序运行环境,用以隔离可疑软件中的病毒或者对计算机有害的行为。比如浏览器就是一个Sandbox环境,它加载并执行远程的代码,但对其加以诸多限制,比如禁止跨域请求、不允许读写本地文件等等。这个概念也会被引用至模块化开发的设计中,让各个模块能相对独立地拥有自己的执行环境而不互相干扰。随着前端技术的发展以及nodejs的崛起,JavaScript的模块化开发也进入了大众的视线。那么问题来了,在JavaScript的模块化中怎样实现Sandbox呢?我们分Browser端和服务器端分别探讨一下Sandbox的实现方式。
第一种比较传统的实现模块化的方式便是Namespacing。
var myApp = {};
myApp.module1 = function(){};
通过前缀式的名称解析可以达到调用不同的模块,并且不同的模块变量环境被封装到了对应的全局变量属性中。然而这并不是真正意义上的Sandbox,这样的做法最终仍然需要暴露出一个全局变量(即myApp),这对所有的模块是透明的,埋下了全局环境被污染的隐患。
那么有没有别的方法可以将变量的作用域隔离开呢?
众所周知,JavaScript变量的作用域是函数体,因此,利用函数体将执行环境包裹起来便成了实现Sandbox的一种可行方案,而YUI3恰巧就是这么做的。我们来看看YUI3的源码片段:
/*global YUI*/
/*global YUI_config*/
var YUI = function() {
var i = 0,
Y = this,
args = arguments,
l = args.length,
instanceOf = function(o, type) {
return (o && o.hasOwnProperty && (o instanceof type));
},
gconf = (typeof YUI_config !== 'undefined') && YUI_config;
if (!(instanceOf(Y, YUI))) {
Y = new YUI();
}
/*Do configurations*/
return Y;
}
YUI中全局变量以constructor的形式声明,每次调用时返回一个新的实例。然后YUI中装载模块的语法如下:
YUI().use('sortable', function(Y) {
Y.a = 1;
});
由于每次装载时函数体里的Y都是一个新的实例,于是不同的模块可以互不干扰。如在装载另一个模块的情况下:
YUI().use('node', function(Y) {
console.log(Y.a); // undefined
});
不同的模块下无法访问各自运行环境中定义的变量。
那么这样的模块添加和装载具体是怎样实现的呢?我们再继续研究YUI3的源码,发现其实并不复杂:
add: function(name, fn, version, details) {
details = details || {};
var env = YUI.Env,
mod = {
name: name,
fn: fn,
version: version,
details: details
},
//Instance hash so we don't apply it to the same instance twice
applied = {},
loader, inst, modInfo,
i, versions = env.versions;
env.mods[name] = mod;
versions[version] = versions[version] || {};
versions[version][name] = mod;
/*Add module to instance*/
return this;
}
再通过如下形式,我们可以添加一个sortable模块:
YUI.add('sortable', function (Y, NAME) {
/*Do other things*/
}, '@VERSION@', {"requires": ["dd-delegate", "dd-drop-plugin", "dd-proxy"]});
结合以上代码,YUI的add主要接受了一个module的名称和函数体,随后将其加入到全局。之后调用模块时的代码如下:
use: function() {
var args = SLICE.call(arguments, 0),
callback = args[args.length - 1],
Y = this,
i = 0,
name,
Env = Y.Env,
provisioned = true;
// The last argument supplied to use can be a load complete callback
if (Y.Lang.isFunction(callback)) {
args.pop();
if (Y.config.delayUntil) {
callback = Y._delayCallback(callback, Y.config.delayUntil);
}
} else {
callback = null;
}
if (Y.Lang.isArray(args[0])) {
args = args[0];
}
if (Y.config.cacheUse) {
while ((name = args[i++])) {
if (!Env._attached[name]) {
provisioned = false;
break;
}
}
if (provisioned) {
if (args.length) {
}
Y._notify(callback, ALREADY_DONE, args);
return Y;
}
}
if (Y._loading) {
Y._useQueue = Y._useQueue || new Y.Queue();
Y._useQueue.add([args, callback]);
} else {
Y._use(args, function(Y, response) {
Y._notify(callback, response, args);
});
}
return Y;
}
其完成的工作就是识别参数中的模块名,完成依赖模块的装载和初始化后,最后调用callback函数,并将实例指针抛给它。如此一来,回调函数中的变量环境是纯净的,YUI为每个沙箱维护各自的装载模块和上下文环境,一般情况下不会发生干涉。然而在这样的沙箱中,用户也可以无节制地使用一些全局变量如window、document等,因此YUI的沙箱事实上是靠“规约”来约束的,本质上并不是完全意义的沙箱。用户如果能够按照规约来处理代码,仍然可以享受他=它带来的安全机制。关于这一观点以及模拟YUI沙箱的实现,可参见周爱民先生的漫谈B端的沙箱技术。
那么如何才能真正地隔离执行环境呢?我们能想到的是,既然全局变量是一个“多事之地”,如果能将隔离凌驾于它之上,是不是问题就解决了呢?著名的沙箱网站jsFiddle给了我们答案。jsFiddle提供用户上传并执行自己的JavaScript脚本,这就需要一个严密的环境来隔离可能存在的恶意攻击。jsFiddle的方案是通过在页面添加iframe来实现沙箱。由于不同的iframe中运行的是不同的JavaScript引擎实例,因此全局变量也是不同的,iframe中的内容无法操作外部页面的DOM或者本地存储的数据。然而即便如此iframe也存在隐患:如包裹页面仍可以通过自动播放视频、插件和弹出框来干扰外部页面。
面对这个问题,iframe的sandbox属性提供了解决之道,它能对iframe中的内容加以限制,我们可以通过设置sandbox属性达到只在一个低权限环境中加载不可信内容的目的。让我们来看看jsFiddle的Result输出框的实现:
<iframe name="result" sandbox="allow-forms allow-popups allow-scripts allow-same-origin" frameborder="0"></iframe>
其中的sandbox属性值解释如下:
如上,通过白名单的方式,jsFiddle将需要用到的最低权限赋予了输出框体,屏蔽了其他的内容,并且禁用插件加载和video自动播放等自动触发的事件。其他属性还包括:
接下来让我们模仿jsFiddle,利用iframe动手实现一个最简单的接受用户输入js代码并输出执行结果的沙箱。以下参考至Play safely in sandboxed IFrames。首先是框体内部内容,即结果输出页面:
<!-- result.html -->
<!DOCTYPE html>
<html>
<head>
<title>Sandbox</title>
<script>
window.addEventListener('message', function(e) {
var mainWindow = e.source,
result = '';
try {
result = eval(e.data);
} catch(e) {
result = 'eval() threw an exception.';
}
mainWindow.postMessage(result, e.origin);
});
</script>
</head>
</html>
在这个页面里,我们利用html5中iframe间传递消息的postMessage API,输出窗口接受主窗体传来的代码后利用eval()执行,并将结果返回给主窗口。eval()曾是一个相当棘手的东西,因为它会执行任何可能包含恶意的代码,然而在iframe中,一切都是处于sandbox属性的限制之下,eval()就变得非常方便。注意,代码执行最好放在try/catch中,因为如果这些代码违反了sandbox的约束,eval()便会抛出异常。
接下来主页面的关键代码如下:
<!-- index.html -->
<textarea id='code'></textarea>
<button id='submitBtn'>Run</button>
<iframe sandbox='allow-scripts' id='resultFrame' src='result.html'></iframe>
页面定义了一个textarea用于接受用户输入,按钮用以提交,iframe用以执行代码得出结果。
// main.js
window.addEventListener('message', function(e) {
var frame = document.getElementById('resultFrame');
if (e.origin === null && e.source === frame.contentWindow) {
console.log('Result: ' + e.data);
}
});
在主窗体中添加一个message的监听。安全起见,此处在收到message后须先校验源窗体是否为指定窗体。另外在sandbox未添加"allow-same-origin"时消息的origin为null。
document.getElementById('submitBtn').addEventListener('click', evaluate);
function evaluate() {
var frame = document.getElementById('resultFrame');
var code = document.getElementById('code').value;
frame.contentWindow.postMessage(code, '*');
}
剩下的事情便是为提交按钮添加事件,让其将代码内容通过postMessage发送至result窗体。需要提及的是,这里的origin使用"*"的原因和上文的null origin一样,在缺少"allow-same-origin"时iframe并不具备origin,因此只能通过发送给所有origin来传达消息。此处可以做更多的验证。
通过上述的几行代码,我们便可以实现一个简单的js代码执行的沙箱环境了。例子请参见Evalbox Demo。存在的一点问题是,sandbox属性在一些低版本的浏览器中没有得到支持。在一些解决方案中,有的提出了真正重新初始化一个js引擎的做法,如Narrative JavaScript,它可以自行编译和执行代码,达到精确控制沙箱的效果。这在将来或许能得到更多的应用。
服务器端中,nodejs也提供了VM模块来对js代码进行独立的编译和运行,我们也可以利用这个模块来实现沙箱。如下是简单的演示代码:
var vm = require('vm'),
sandbox1,
sandbox2,
jsCode;
init();
jsCode = 'k = 1';
// Run code in sandbox1
vm.runInNewContext(jsCode, sandbox1);
console.log(sandbox1.k); //1
console.log(sandbox2.k); //0
init();
// Run code in sandbox2
vm.runInNewContext(jsCode, sandbox2);
console.log(sandbox1.k); //0
console.log(sandbox2.k); //1
function init() {
sandbox1 = { k: 0 };
sandbox2 = { k: 0 };
}
我们可以看到,通过VM模块提供的runInNewContext接口,可以指定某一段代码在某一个sandbox对象中执行,而在不同的sandbox中,上下文环境是相对独立的,我们可以看到执行过后sandbox1和sandbox2的变量k呈现出不同的结果,变量环境不会被污染。
另一种实现方式是利用child_process模块为js代码spawn出不同的子进程,不同的进程间拥有相对独立的资源,因此这样也能实现沙箱。该方案可以参考Sandbox。以下是它的使用方法:
var s = new Sandbox()
s.run( '1 + 1 + " apples"', function( output ) {
// output.result == "2 apples"
})
分析sandbox的源代码,发现run方法中核心代码如下:
Sandbox.prototype.run = function(code, hollaback) {
var self = this;
// Do initialzations
self.child = spawn(this.options.node, [this.options.shovel], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] });
// Listen
self.child.stdout.on('data', output);
// Pass messages out from child process
// These messages can be handled by Sandbox.on('message', function(message){...});
self.child.on('message', function(message){
if (message === '__sandbox_inner_ready__') {
self.emit('ready');
self._ready = true;
// Process the _message_queue
while(self._message_queue.length > 0) {
self.postMessage(self._message_queue.shift());
}
} else {
self.emit('message', message);
}
});
self.child.on('exit', function(code) {
clearTimeout(timer);
setImmediate(function(){
if (!stdout) {
hollaback({ result: 'Error', console: [] });
} else {
var ret;
try {
ret = JSON.parse(stdout);
} catch (e) {
ret = { result: 'JSON Error (data was "'+stdout+'")', console: [] }
}
hollaback(ret);
}
});
});
// Go
self.child.stdin.write(code);
self.child.stdin.end();
// Send a message to the code running inside the sandbox
// This message will be passed to the sandboxed
// code's `onmessage` function, if defined.
// Messages posted before the sandbox is ready will be queued
Sandbox.prototype.postMessage = function(message) {
var self = this;
if (self._ready) {
self.child.send(message);
} else {
self._message_queue.push(message);
}
};
module.exports = Sandbox;
}
在调用方法后,sandbox利用spawn函数获取一个子进程,令子进程监听传入的数据流,随后利用stdin.write()
将代码写入子进程的输入流,最后将结果传入回调函数。另外Sandbox的原型中还有postMessage方法以及对message的监听,用以为子进程和主进程间提供通信。
随着技术的日新月异,JavaScript的沙箱机制也将日趋完善,而用户在平台上获得更多自由操作空间的同时也无需担心其他用户应用的干扰,这或许将带来更多新奇的、实用的平台业务。