1、什么是沙箱
在计算机安全中,沙箱(Sandbox)是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或者不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行。
- 动态执行代码方法
- eval
- new Function
// eval
const person = eval("({name:'张三'})");
console.log(person.name);
// new Function
const a = 1;
function sandbox() {
const a = 2;
return new Function('return a;'); // 这里的 a 指向最上面全局作用域内的 1
}
const f = sandbox();
console.log(f());
2、沙箱的应用场景
-
在线代码编辑器,如 CodeSanbox、JS Bin 等在线代码编辑器在执行脚本时都会将程序放置在一个沙箱中,防止程序访问/影响主页面
-
Vue 模板表达式的计算是运行在一个沙盒之中的,在模板字符串中的表达式只能获取部分全局对象
-
插件机制,许多应用程序提供了插件(Plugin)机制,开发者可以书写自己的插件程序实现某些自定义功能。但插件的开发和运行都要遵循宿主程序的限制,这是因为插件是运行在沙箱中
-
部分微前端框架,如qiankun
总而言之,只要遇到不可信的第三方代码,我们就可以使用沙箱将代码进行隔离,从而保障外部程序的稳定运行。如果不做任何处理地执行不可信代码,在前端中最直观的副作用/危害就是污染、篡改全局 window 状态,影响主页面功能。
3、JavaScript中的沙箱实现
要实现一个沙箱,其实就是去制定一套程序执行机制,在这套机制的作用下沙箱内部程序的运行不会影响到外部程序的运行。
- 基于作用域隔离
- 原生浏览器对象模拟
- 天然的优质沙箱iframe
3.1、基于作用域隔离
要实现这样的一个效果,最直接的想法是程序中访问的所有变量均来自可靠或者自主实现的上下文环境而不会从全局的执行环境中取值,那么要实现变量的访问均来自一个可靠的上下文环境,我们需要为待执行程序构造一个作用域。
3.1.1、function scope
我们知道在JavaScript中的作用域(scope)只有全局作用域(global scope)、函数作用域(function scope)以及ES6以后的块级作用域(block scope)。如果要将一段代码中的变量、函数等的定义隔离出来,受限于JavaScript对作用域的控制,只能将这段代码封装到一个Function中,通过function scope来达到作用域隔离的目的。
(function foo(){
const a = 1;
console.log(a);
})();// 无法从外部访问变量
console.log(a) // 抛出错误:"Uncaught ReferenceError: a is not defined"
// 执行上下文对象
const ctx =
func: variable => {
console.log(variable)
},
foo: 'foo'
}
// 最简陋的沙箱
function poorestSandbox(code, ctx) {
eval(code) // 为执行程序构造了一个函数作用域
}
// 待执行程序
const code = `
ctx.foo = 'bar'
ctx.func(ctx.foo)
`
poorestSandbox(code, ctx) // bar
在函数作用域内的不能从外部访问,它拥有独立的词法作用域,而且也不会污染全局。但是在函数内部可以访问到外部的作用域,只能说是一种“君子沙箱”
3.1.2、with
with是JavaScript中的一个关键字,扩展一个语句的作用域链。with会在作用域链的顶端添加一个新的作用域,改作用域的变量会加入with传入的对象,因此相较于外部环境其内部代码在查找变量时会优先在该对象上进行查找。
// 执行上下文对象
const ctx = {
a: 2
}
// 非常简陋的沙箱
function veryPoorSandbox(code) {
code = 'with (sandbox) {' + code + '}'
return new Function('sandbox', code);
}
// 待执行程序
const a = 1
const code = `console.log(a)`
veryPoorSandbox(code)(ctx) // 2
问题来了,在提供的上下文对象中没有找到某个变量时,代码仍会沿着作用域链向上查找,所以它只能算一个“半沙盒”吧。
我们希望沙箱中的代码只在手动提供的上下文对象中查找变量,如果上下文对象中不存在该变量则直接报错或返回 undefined。
3.2、原生浏览器对象模拟
3.2.1、基于Proxy的沙箱机制
为了解决上述抛出的问题,我们借助 ES2015 的 Proxy,Proxy 可以代理一个对象,从而拦截并定义对象的基本操作。
function sandbox(code) {
code = 'with (sandbox) {' + code + '}'
const fn = new Function('sandbox', code)
return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {
get(target, key) {
return target[key]
}
})
return fn(sandboxProxy)
}
}
const test = {
a: 'a',
func(value){
console.log(value)
}
}
const code = 'func(a);' // a
sandbox(code)(test)
function sandbox(code) {
code = 'with (sandbox) {' + code + '}'
const fn = new Function('sandbox', code)
return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {
get(target, key) {
return target[key]
}
})
return fn(sandboxProxy)
}
}
const test = {
a: 'a',
func(value){
console.log(value)
}
}
const testB = 'b'
const code = 'func(a); func(testB)'
sandbox(code)(test)
Proxy中的 get 和 set 方法只能拦截已存在于代理对象中的属性,对于代理对象中不存在的属性这两个钩子无感知。因此我们还需要使用 Proxy.has() 来拦截 with 代码块中的任意变量的访问。
function sandbox(code) {
code = 'with (sandbox) {' + code + '}'
const fn = new Function('sandbox', code)
return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {
has(target,key){return true},
get(target, key) {
return target[key]
}
})
return fn(sandboxProxy)
}
}
const test = {
a: 'a',
func(value){
console.log(value)
}
}
const testB = 'b'
const code = 'func(a); func(testB)' // 'a' undefined
sandbox(code)(test)
这样,我们就可以通过白名单的形式来控制变量的访问了。在白名单内的变量可以正常走作用域链的访问方式,不在白名单内的变量会继续判断是否存在沙箱自行维护的上下对象中,存在则正常访问,不存在则直接报错。
function sandbox(code) {
code = 'with (sandbox) {' + code + '}'
const fn = new Function('sandbox', code)
const access_white_list = ['testB']
return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {
has: (target, prop) => { // has 可以拦截 with 代码块中任意属性的访问
if (access_white_list.includes(prop)) { // 在可访问的白名单内,可继续向上查找
return target.hasOwnProperty(prop)
}
if (!target.hasOwnProperty(prop)) {
throw new Error(`Invalid expression - ${prop}! You can not do that!`)
}
return true
},
get(target, key) {
return target[key]
}
})
return fn(sandboxProxy)
}
}
const test = {
a: 'a',
func(value){
console.log(value)
}
}
const testB = 'b';
const testC = 'c'
const code = 'func(a); func(testB); func(testC)' // a b error
sandbox(code)(test)
除了使用白名单数组外,官方给了一个解决方案:Symbol.unscopables。Symbol对象的Symbol.unscopables 属性,指向一个对象。该对象指定了使用with关键字时,哪些属性会被 with 环境排除。
function sandbox(code) {
code = 'with (sandbox) {' + code + '}'
const fn = new Function('sandbox', code)
const unscopables = { testB: true};
return function (sandbox) {
const sandboxProxy = new Proxy(sandbox, {
has: (target, prop) => {
return true
},
get(target, key) {
if (key === Symbol.unscopables) return unscopables;
return target[key]
}
})
return fn(sandboxProxy)
}
}
const test = {
a: 'a',
func(value){
console.log(value)
}
}
const testB = 'b';
const testC = 'c'
const code = 'func(a); func(testB); '
sandbox(code)(test)
3.2.2、基于属性 diff 的沙箱机制
由于 Proxy 为 ES6 引入的 API,在不支持 ES6 的环境下,我们可以通过一类原始的方法来实现所要的沙箱,即利用普通对象针对 window 属性值构建快照,用于环境的存储与恢复,并在应用卸载时对 window 对象修改做 diff 用于子应用环境的更新保存,该方法会污染全局 window。
qiankun 中也有该降级方案,被称作 SnapshotSandbox。
3.3、天然的优质沙箱iframe
不论是 Proxy 还是 diff ,其沙箱机制的方案都是通过模拟和代理来实现一个环境隔离。由于是模拟,因此子程序使用所有全局对象的同时不可避免的会影响外部的全局状态。
而 iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境实现了与主环境的隔离。在 iframe 中运行的脚本程序访问到的全局对象均是当前 iframe 执行上下文提供的,不会影响其父页面的主体功能,因此使用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法。
4、qiankun沙箱实现
- SnapshotSandbox
- LegacySandbox
- ProxySandbox
4.1、SnapshotSandbox
快照沙箱实现来说比较简单,主要用于不支持 Proxy 的低版本浏览器,原理是基于 diff 来实现的,在子应用激活或者卸载的时分别去通过快照的形式记录或者还原状态来实现沙箱。SnapshotSandbox 会濡染全局 window。
function iter(obj, callbackFn) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
callbackFn(prop);
}
}
}
/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
class SnapshotSandbox {
constructor(name) {
this.name = name;
this.proxy = window;
this.type = 'Snapshot';
this.sandboxRunning = true;
this.windowSnapshot = {};
this.modifyPropsMap = {};
this.active();
}
//激活
active() {
// 记录当前快照
this.windowSnapshot = {};
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
//还原
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
}
let sandbox = new SnapshotSandbox();
//test
((window) => {
window.name = '张三'
window.age = 18
console.log(window.name, window.age) // 张三,18
sandbox.inactive() // 还原
console.log(window.name, window.age) // undefined,undefined
sandbox.active() // 激活
console.log(window.name, window.age) // 张三,18
})(sandbox.proxy);
4.2、LegacySandBox
LegacySandbox 是基于 Proxy 的单实例沙箱,当我们只针对全局运行环境进行代理赋值记录,而不从中取值,那么这样的沙箱只是作为我们记录变化的一种手段,而实际操作仍在主应用运行环境中对 window 进行读写,因此这类沙箱也只能支持单实例模式。
//legacySandBox
const callableFnCacheMap = new WeakMap();
function isCallable(fn) {
if (callableFnCacheMap.has(fn)) {
return true;
}
const naughtySafari = typeof document.all === 'function' && typeof document.all === 'undefined';
const callable = naughtySafari ? typeof fn === 'function' && typeof fn !== 'undefined' : typeof fn ===
'function';
if (callable) {
callableFnCacheMap.set(fn, callable);
}
return callable;
};
function isPropConfigurable(target, prop) {
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
return descriptor ? descriptor.configurable : true;
}
function setWindowProp(prop, value, toDelete) {
if (value === undefined && toDelete) {
delete window[prop];
} else if (isPropConfigurable(window, prop) && typeof prop !== 'symbol') {
Object.defineProperty(window, prop, {
writable: true,
configurable: true
});
window[prop] = value;
}
}
function getTargetValue(target, value) {
/*
仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类。目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
@warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
*/
if (isCallable(value) && !isBoundedFunction(value) && !isConstructable(value)) {
const boundValue = Function.prototype.bind.call(value, target);
for (const key in value) {
boundValue[key] = value[key];
}
if (value.hasOwnProperty('prototype') && !boundValue.hasOwnProperty('prototype')) {
Object.defineProperty(boundValue, 'prototype', {
value: value.prototype,
enumerable: false,
writable: true
});
}
return boundValue;
}
return value;
}
/**
* 基于 Proxy 实现的沙箱
*/
class SingularProxySandbox {
/** 沙箱期间新增的全局变量 */
addedPropsMapInSandbox = new Map();
/** 沙箱期间更新的全局变量 */
modifiedPropsOriginalValueMapInSandbox = new Map();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
currentUpdatedPropsValueMap = new Map();
name;
proxy;
type = 'LegacyProxy';
sandboxRunning = true;
latestSetProp = null;
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
// console.log(' this.modifiedPropsOriginalValueMapInSandbox', this.modifiedPropsOriginalValueMapInSandbox)
// console.log(' this.addedPropsMapInSandbox', this.addedPropsMapInSandbox)
//删除添加的属性,修改已有的属性
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name) {
this.name = name;
const {
addedPropsMapInSandbox,
modifiedPropsOriginalValueMapInSandbox,
currentUpdatedPropsValueMap
} = this;
const rawWindow = window;
//Object.create(null)的方式,传入一个不含有原型链的对象
const fakeWindow = Object.create(null);
const proxy = new Proxy(fakeWindow, {
set: (_, p, value) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
const originalValue = rawWindow[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
rawWindow[p] = value;
this.latestSetProp = p;
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(_, p) {
//避免使用 window.window 或者 window.self 逃离沙箱环境,触发到真实环境
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = rawWindow[p];
return getTargetValue(rawWindow, value);
},
has(_, p) { //返回boolean
return p in rawWindow;
},
getOwnPropertyDescriptor(_, p) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
// 如果属性不作为目标对象的自身属性存在,则不能将其设置为不可配置
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
},
});
this.proxy = proxy;
}
}
let sandbox = new SingularProxySandbox();
((window) => {
window.name = '张三';
window.age = 18;
window.sex = '男';
console.log(window.name, window.age,window.sex) // 张三,18,男
sandbox.inactive() // 还原
console.log(window.name, window.age,window.sex) // undefined,undefined,undefined
sandbox.active() // 激活
console.log(window.name, window.age,window.sex) // 张三,18,男
})(sandbox.proxy); //test
4.3、ProxySandbox
LegacySandbox 由于会修改 window 对象,在多个实例运行时肯定会存在冲突。因此,该沙箱模式只能在单实例场景下使用,而当我们需要同时起多个实例时,ProxySandbox 便登场了。
ProxySandbox 的方案是同时使用 Proxy 给子应用运行环境做了 get 与 set 拦截(对 fakeWindow 进行代理,而这个对象是通过 createFakeWindow 方法创建,这个方法是将 window 的 document、location、top、window 等属性拷贝一份)。沙箱在初始构造是建立一个状态池,当应用操作 window 时,赋值通过 set 拦截器将变量写入状态池,而取值也是从状态池中优先寻找对应的属性。由于状态池与子应用绑定,那么运行多个子应用,便可以产生多个相互独立的沙箱环境。
由于取值赋值均在建立的状态池上操作。因此,在以上两种沙箱环境下激活和卸载需要做的工作,这里就不需要了。
function createFakeWindow(global: Window) {
// map always has the fastest performance in has check scenario
// see https://jsperf.com/array-indexof-vs-set-has/23
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
/*
copy the non-configurable property of global to fakeWindow
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
Object.getOwnPropertyNames(global)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
/*
make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
> The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
*/
if (
p === 'top' ||
p === 'parent' ||
p === 'self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
descriptor.configurable = true;
/*
The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
// freeze the descriptor to avoid being modified by zone.js
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
5、ShadowRealm
ShadowRealm 是 ECMAScript 标准提案,旨在创建一个独立的全局环境,它的全局对象包含自己的内建函数与对象,有自己独立作用域,方案当前处于 step3 阶段。提案地址:https://github.com/tc39/proposal-shadowrealm
declare class ShadowRealm {
constructor();
// 同步执行代码字符串,类似 eval()。
evaluate(sourceText: string): PrimitiveValueOrCallable;
// 返回一个 Promise 对象,异步执行代码字符串。
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
}
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`'ab' + 'cd'`) === 'abcd'
);
// main.js
const sr = new ShadowRealm();
const wrappedSum = await sr.importValue('./my-module.js', 'sum');
console.assert(wrappedSum('hi', ' ', 'folks', '!') === 'hi ConardLi!');
// my-module.js
export function sum(...values) {
return values.reduce((prev, value) => prev + value);
}
参考:
import-html-entry