创建 Javascript 沙箱环境

背景

在Nodejs里面如果想构建沙箱环境的话可以通过VM模块构建一个虚拟的上下文环境,运行指定的代码,比如

1
2
3
4
5
const vm = require('vm');
const sandbox = { a: 1, b: 2 };
const script = new vm.Script('a + b');
const context = new vm.createContext(sandbox);
script.runInContext(context);

但是在前端,如果想要执行一段代码的话只能通过eval函数或者new Function构建function对象的方式来运行,但是这两种方式都没有办法避免要执行的代码访问当前环境的上下文,比如

1
2
3
eval('console.log(window)');
const f = new Function('console.log(window)');
f();

上面两段代码放在浏览器环境下执行的话都会执行console,并且把window打印出来。
evalnew Function 区别在于后者创建的函数的执行作用域是全局作用域,也就是global或者window下。
那我们怎么能够构建一个干净的沙箱环境呢,让内部的代码执行的时候只能访问我们传递进去的变量。

思路

with 关键字

首先我们想要构建上下文环境,所以肯定首先想到的是使用with关键字
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const a = {
c: 'cc',
b: 'bb',
}
d = 'dd'

function compileCode(context) {
return (code) => {
const c = 'with(sandbox){ '+code+' }';
return (new Function('sandbox', c).bind(null, context))();
}
}
compileCode(a)('console.log(c + b)');
compileCode(a)('console.log(d)');

但是这里会遇到一个问题,虽然上下文对象能够正确的构建,但是我们期望的应该是consoled都应该是不能访问才对。
这个原因是使用 with 首先会寻找 sandbox 中的变量,如果不存在,会往上追溯 global 对象。

Proxy

Proxy可以在访问对象的时候增加一个拦截器,阻止往上追溯global。我们将上面的代码稍微改一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const a = {
c: 'cc',
b: 'bb',
}
d = 'dd'

function compileCode(context) {
const proxyContext = new Proxy(context, {
has(target, p) {
return true;
}
})
return (code) => {
const c = 'with(sandbox){ '+code+' }';
return (new Function('sandbox', c).bind(null, proxyContext))();
}
}
console.log(compileCode(a)('return c + b'));
console.log(compileCode(a)('return d'));

可以发现我们把下面的console.log移动到了外面,这是因为在这段代码中, 函数内部已经访问不到全局的console。
目前看起来已经满足需求,但是es6同样有办法绕过with作用域。

Symbol.unscopables

执行下面这段代码,可以看到打印出来的值是 ccglobal_bb。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const a = {
c: 'cc',
b: 'bb',
[Symbol.unscopables] : {
b: true
}
}
d = 'global_dd'
b = 'global_bb'

function compileCode(context) {
const proxyContext = new Proxy(context, {
has() {
return true;
}
})
return (code) => {
const c = 'with(sandbox){ '+code+' }';
return (new Function('sandbox', c).bind(null, proxyContext))();
}
}
console.log(compileCode(a)('return c + b'));
console.log(compileCode(a)('return d'));

Symbol.unscopables 是 ES6 新增加的api,用于指定对象值,其对象自身和继承的从关联对象的 with 环境绑定中排除的属性名称。
也就是值为true的键在作用域内取值的时候将不会从 with 的对象上去查找。所以我们需要再做对这个属性做一个特殊处理,最终代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const a = {
c: 'cc',
b: 'bb',
[Symbol.unscopables] : {
b: true
}
}
d = 'global_dd'
b = 'global_bb'

function compileCode(context) {
const proxyContext = new Proxy(context, {
has() {
return true;
},
get(target, key, receiver) {
// 加固,防止逃逸
if (key === Symbol.unscopables) {
return {};
}
return Reflect.get(target, key, receiver);
}
})
return (code) => {
const c = 'with(sandbox){ '+code+' }';
return (new Function('sandbox', c).bind(null, proxyContext))();
}
}
console.log(compileCode(a)('return c + b'));
console.log(compileCode(a)('return d'));

目前为止看起来实现了我们想要的功能,但是由于实现方式是通过字符串拼接执行,会有很多方法来绕过去,比如下面两种方式。

1
2
compileCode(a)('}console.log(2);if(true){'); // 通过字符串拼接绕过
compileCode(a)('/2/.constructor.constructor("console.log(b)")();') // 通过构建new Function绕过

结语

由于 Javascript 本身的灵活性,很难构建一个比较完善的同步沙箱环境,有思路感觉可以解决上述问题,以后有时间试一下。 一是实现js解释器,
在前端解析代码片段然后执行。二是通过 iframe 实现,但是本身会变成异步执行。
第一种思路可以参考:https://github.com/bramblex/jsjs
第二种思路可以参考:https://github.com/snanovskyi/vm-browser