什么是沙箱?

也称作:“沙箱/沙盒/沙盘”。沙箱是一种安全机制,为运行中的程序提供隔离环境。通常是作为一些来源不可信、具破坏力或无法判定程序意图的程序提供实验之用。沙箱能够安全的执行不受信任的代码,且不影响外部实际代码影响的独立环境。

javascript中沙箱的使用场景

  1. 在线代码编辑器:相信大家都有使用过一些在线代码编辑器,而这些代码的执行,基本都会放置在沙箱中,防止对页面本身造成影响
  2. vue 模板中表达式计算:vue 模板中表达式的计算被放在沙盒中,只能访问全局变量的一个白名单,如 Math 和 Date 。你不能够在模板表达式中试图访问用户定义的全局变量。
  3. vue 的服务端渲染:vue 的服务端渲染实现中,通过创建沙箱执行前端的 bundle 文件;在调用 createBundleRenderer 方法时候,允许配置 runInNewContext 为 true 或 false 的形式,判断是否传入一个新创建的 sandbox 对象以供 vm 使用

javascript中沙箱实现

一、跟浏览器宿主环境一致的沙箱实现

1.构建闭包环境

我们知道在 JavaScript 中的作用域(scope)只有全局作用域(global scope)、函数作用域(function scope)以及从 ES6 开始才有的块级作用域(block scope)。

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
(function foo(){
const a = 1;
console.log(a);
})();// 无法从外部访问变量

console.log(a) // 抛出错误:"Uncaught ReferenceError: a is not defined"

(function (window) {
var jQuery = function (selector, context) {
return new jQuery.fn.init(selector, context);
}
jQuery.fn = jQuery.prototype = function () {
//原型上的方法,即所有jQuery对象都可以共享的方法和属性
}
jQuery.fn.init.prototype = jQuery.fn;
window.jQeury = window.$ = jQuery; //如果需要在外界暴露一些属性或者方法,可以将这些属性和方法加到window全局对象上去
})(window);


// 当将 IIFE 分配给一个变量,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。
const result = (function () {
const name = "张三";
return name;
})();

console.log(result); // "张三"

2.原生浏览器对象模拟

模拟原生浏览器对象的目的是为了防止闭包环境,操作原生对象,篡改污染原生环境,完成模拟浏览器对象之前我们需要先关注几个不常用的 API。

eval

eval 函数可将字符串转换为代码执行,并返回一个或多个值;eval代码内部可以沿着作用域链往上找,篡改全局变量。

1
2
3
const b = eval("({name:'张三'})");
console.log(b.name);
console.log(eval( this.window === window )); // true

new Function

Function构造函数创建一个新的 Function 对象。直接调用这个构造函数可用于动态创建函数。
new Function ([arg1[, arg2[, …argN]],] functionBody)

1
2
3
4
5
6
7
8
9
10
11
const sum = new Function('a', 'b', 'return a + b'); 
console.log(sum(1, 2));//3


let a = 1;
function sandbox() {
let a = 2;
return new Function('return a;'); // 这里的 a 指向最上面全局作用域内的 1
}
const f = sandbox();
console.log(f());

与 eval 不同的是 Function 创建的函数只能在全局作用域中运行,它无法访问局部闭包变量,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造器创建时所在的作用域的变量。new Function()是 eval()更好替代方案。它具有卓越的性能和安全性,但仍没有解决访问全局的问题。

with

with 是 JavaScript 中一个关键字,扩展一个语句的作用域链。它允许半沙盒执行。那什么叫半沙盒?语句将某个对象添加到作用域链的顶部,如果在沙盒中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则将拋出 ReferenceError。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 严格模式下以下代码运行会有问题
function sandbox(o) {
with (o){
c=2;
d=3;
// 0,1,2,3
// 每个变量首先被认为是一个局部变量,
// 如果局部变量与 obj 对象的某个属性同名,则这个局部变量会指向 obj 对象属性。
console.log(a,b,c,d);
}
}
const f = {
a:0,
b:1
}
sandbox(f);
console.log(f); // {a: 0, b: 1}
console.log(c,d); // 2,3。 c、d被泄露到window对象上

究其原理,with在内部使用in运算符。对于块内的每个变量访问,它都在沙盒条件下计算变量。如果条件是 true,它将从沙盒中检索变量。否则,就在全局范围内查找变量。但是 with 语句使程序在查找变量值时,都是先在指定的对象中查找。所以对于那些本来不是这个对象的属性的变量,查找起来会很慢,对于有性能要求的程序不适合(JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符)。with 也会导致数据泄漏(在非严格模式下,会自动在全局作用域创建一个全局变量)

with + new Function

配合 with 用法可以稍微限制沙盒作用域,先从当前的 with 提供对象查找,但是如果查找不到依然还能从更上面的作用域获取,污染或篡改全局环境

1
2
3
4
5
6
7
8
9
10
11
function sandbox (src) {
src = 'with (sandbox) {' + src + '}';
return new Function('sandbox', src);
}
const str = `
let a = 1;
window.name="张三";
console.log(a); // 打印:1
`;
sandbox(str)({});
console.log(window.name);//'张三'

存在的问题:

  1. eval 是全局对象的一个函数属性,执行的代码拥有着和应用中其它正常代码一样的的权限,它能访问「执行上下文」中的局部变量,也能访问所有「全局变量」,在这个场景下,它是一个非常危险的函数
  2. 使用 Function 构造器生成的函数,并不会在创建它的上下文中创建闭包,一般在全局作用域中被创建。当运行函数的时候,只能访问自己的本地变量和全局变量,不能访问 Function 构造器被调用生成的上下文的作用域
  3. with 一样的问题,它首先会在传入的对象中查找对应的变量,如果找不到就会往更上层的全局作用域去查找,所以也避免不了污染或篡改全局环境
Proxy

ES6 Proxy 用于修改某些操作的默认行为,等同于在语言层面做出修改,属于一种“元编程”

1
2
3
4
5
6
7
8
9
10
11
12
13
function evalute(code,sandbox) {
sandbox = sandbox || Object.create(null);
const fn = new Function('sandbox', `with(sandbox){return (${code})}`);
const proxy = new Proxy(sandbox, {
has(target, key) {
// 让动态执行的代码认为属性已存在
return true;
}
});
return fn(proxy);
}
evalute('1+2') // 3
evalute('console.log(1)') // Cannot read property 'log' of undefined

我们知道无论 eval 还是 function,执行时都会把作用域一层一层向上查找,如果找不到会一直到 global,那么利用 Proxy 的原理就是,让执行了代码在 sandobx 中找的到,以达到「防逃逸」的目的。

3.iframe的沙箱环境实现

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
31
32
33
34
const parent = window;
const frame = document.createElement('iframe');

// 限制代码 iframe 代码执行能力
frame.sandbox = 'allow-same-origin';

const data = [1, 2, 3, 4, 5, 6];
let newData = [];

// 当前页面给 iframe 发送消息
frame.onload = function (e) {
frame.contentWindow.postMessage(data);
};

document.body.appendChild(frame);

// iframe 接收到消息后处理
const code = `
return dataInIframe.filter((item) => item % 2 === 0)
`;
frame.contentWindow.addEventListener('message', function (e) {
const func = new frame.contentWindow.Function('dataInIframe', code);
// 给副页面也送消息
parent.postMessage(func(e.data));
});

// 父页面接收 iframe 发送过来的消息
parent.addEventListener(
'message',
function (e) {
console.log('parent - message from iframe:', e.data);
},
false,
);

二、nodejs中沙箱实现

vm

VM是 Node.js 默认提供的一个内建模块,VM 模块提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。JavaScript 代码可以被编译并立即运行,或编译、保存然后再运行。

1
2
3
4
5
6
const vm = require('vm');
const script = new vm.Script('m + n'); // 先new一个脚本执行的容器实例
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox); // 实例化一个执行上下文
const res = script.runInContext(context); // 运行
console.log(res); // 打印:3

vm2

总结

运行不信任的代码是非常困难的,只依赖软件模块作为沙箱技术,防止不受信任代码用于非正当用途是不得已的决定。这可能促使云上SAAS应用的不安全,因为通过逃逸出沙箱进程多个租户间的数据可能被访问(主进程数据获取),这样你就可能可以通过session,secret等来潜入其他租户。一个更安全的选择是依赖于硬件虚拟化,比如每个租户代码在独立的docker容器或AWS Lambada Function 中执行会是更好的选择。


文章来源