前言
沙箱,即 sandbox,顾名思义,就是让你的程序跑在一个隔离的环境下,它是一种用于隔离和限制执行环境的机制。JavaScript 沙箱提供了一种安全的执行环境,可以在其中运行不受信任的代码,同时限制其对系统资源和外部环境的访问。
特性
- 隔离,隔离是为了保证当前执行代码不影响整个平台的代码
- 插入,沙箱允许插入平台的内置对象
- 容错,沙箱内代码即使有错误,也不影响整个平台执行
使用场景
-
执行 JSONP 请求回来的字符串时或引入不知名第三方 JS 库时,可能需要创造一个沙箱来执行这些代码。
-
在线代码编辑器,如 CodeSanbox 等在线代码编辑器在执行脚本时都会将程序放置在一个沙箱中,防止程序访问/影响主页面。
-
模版表达式计算:例如Vue 模板表达式的计算是运行在一个沙盒之中的,在模板字符串中的表达式只能获取部分全局对象,这一点官方文档有提到,详情可参阅源码。
-
执行第三方js库:当你有必要执行第三方js的时候,而这份js文件又不一定可信的时候
-
微前端应用中,主子应用隔离
-
许多应用程序提供了插件(Plugin)机制,开发者可以书写自己的插件程序实现某些自定义功能。开发过插件的同学应该知道开发插件时会有很多限制条件,这些应用程序在运行插件时需要遵循宿主程序制定的运行规则,插件的运行环境和规则就是一个沙箱。例如下图是
Figma插件的运行机制: -
总而言之
- 要解析或执行不可信的JS的时候,
- 要隔离被执行代码的执行环境的时候,
- 要对执行代码中可访问对象进行限制的时候
实现方式
IEEE
通过给一段代码包裹一层函数可以实现作用的隔离,这通常基于 IIFE 立即执行函数来实现,也被称作自执行匿名函数。使用 IIFE,外界不能访问函数内的变量,同时由于作用域的隔离,也不会污染全局作用域,通常用于插件和类库的开发,比如webpack打包后的代码。
//bundle.js
(() => {
var __webpack_modules__ = ({
"./src/index.js": (() => {
eval("console.log('这是个测试脚本!用于分析 webpack 打包后代码。');");
})
});
var __webpack_exports__ = {};//忽略,目前用不到它。
__webpack_modules__["./src/index.js"]();
})();
但 IIFE 只能实现一个简易的沙箱,并不算一个独立的运行环境,函数内部可以访问上下文作用域,有污染作用域的风险。
const user = { name: 'zhangsan' };
(() => {
user.name = 'lisi';
})();
with + new Function
先来说明下with的用处,来源于MDN
with:JavaScript 查找某个未使用命名空间的变量时,会通过作用域链来查找,作用域链是跟执行代码的 context 或者包含这个变量的函数有关。'with'语句将某个对象添加到作用域链的顶部,如果在 statement 中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值 --- MDN
说明:为什么不使用eval
eval()是一个危险的函数,它使用与调用者相同的权限执行代码。如果你用eval()运行的字符串代码被恶意方(不怀好意的人)修改,你最终可能会在你的网页/扩展程序的权限下,在用户计算机上运行恶意代码。更重要的是,第三方代码可以看到某一个eval()被调用时的作用域,这也有可能导致一些不同方式的攻击。相似的Function就不容易被攻击。
eval()通常比其他替代方法更慢,因为它必须调用 JS 解释器,而许多其他结构则可被现代 JS 引擎进行优化。此外,现代 JavaScript 解释器将 JavaScript 转换为机器代码。这意味着任何变量命名的概念都会被删除。因此,任意一个 eval 的使用都会强制浏览器进行冗长的变量名称查找,以确定变量在机器代码中的位置并设置其值。另外,新内容将会通过
eval()引进给变量,比如更改该变量的类型,因此会强制浏览器重新执行所有已经生成的机器代码以进行补偿。但是(谢天谢地)存在一个非常好的 eval 替代方法:只需使用 window.Function。这有个例子方便你了解如何将eval()的使用转变为Function()。
利用 new Function 创建的函数不需要考虑当前所在作用域,默认被创建于全局环境,因此运行时只能访问全局变量和自身的局部变量。
const ctx = {
test(flag){
console.log(flag);
}
};
function sandbox(code) {
code = "with (ctx) {" + code + "}";
return new Function("ctx", code);
}
const code = `
const name = 'zhangsan'
test(name)
`;
sandbox(code)(ctx);
利用with和Function,可以防止代码访问上下文作用域,但是对于全局对象,仍然可以访问并篡改,有污染全局的风险。
基于Proxy实现
Proxy 可以代理对象,那么我们同样可以用其代理 window——浏览器环境中的全局变量。每个 Web 应用都会与 window 交互,无数的 API 也同样挂靠在 window 上,要实现全局环境的安全访问,首先需要 window 隔开。
主要实现思路是基于 get、set、has、getOwnPropertyDescriptor 等关键拦截器对 window 进行代理拦截(如下如有涉及代码,我们主要关注 get 与 set 两类拦截器)
在实现之前,我们先来了解几个前置的概念
沙箱逃逸
沙箱保证了内部程序执行的安全运行,但是极端情况下仍然有些人试图摆脱这种束缚,入侵内部程序,这种行为被称为沙箱逃逸。
沙箱逃逸的几种方式:
- 访问沙箱执行上下文中某个对象内部属性时,如:通过window.parent
- 利用沙箱执行上下文中对象的某个内部属性,Proxy 只可以拦截对象的一级属性,例如下面的上下文对象
- 通过访问原型链实现逃逸
case1
const ctx = {
a: {
b: {
c: 1
},
},
};
// 编写的内部沙箱逃逸程序
const code = `
a.b.c = 2;
`;
由于 a.b属性没有被监听,所有沙箱内部的代码仍然可以篡改其属性
case2
const code = `
({}).__proto__.toString = () => {};
const a = 1;
a.__proto__.toFixed = () => '0';
`;
基于原型链的方式,还有一种更简单的方案实现沙箱逃逸,定义一个 JavaScript 对象字面量,通过该字面量遍历原型链向上查找,对原型进行篡改,实现沙箱逃逸
Symbol.unscopables
Symbol.unscopables指用于指定对象值,其对象自身和继承的从关联对象的 with 环境绑定中排除的属性名称。
当我们在 unscopables 对象上将属性设置为 true,将使其 unscopable 并且因此该属性也将不会在词法环境变量中出现。我们来看一个简单例子,以了解其效果:
const object1 = {
property1: 42
};
object1[Symbol.unscopables] = {
property1: true
};
with (object1) {
console.log(property1);
// expected output: Error: property1 is not defined
}
在 JavaScript 中,有许多默认设置了 Symbol.unscopables 的属性。如:
console.log(Object.keys(Array.prototype[Symbol.unscopables]));
Output:
[ 'copyWithin', 'entries', 'fill', 'find', 'findIndex', 'flat', 'flatMap', 'includes', 'keys', 'values']
准备工作就绪,接下来看看基于Proxy如何实现js沙箱
基于Proxy我们可以实现单实例和多示例两种模式
单实例模式
单实例只针对全局运行环境进行代理赋值记录,而不从中取值,那么这样的沙箱只是作为我们记录变化的一种手段,而实际操作仍在主应用运行环境中对 window 进行了读写,因此这类沙箱也只能支持单实例模式,qiankun 在实现上将其命名为 LegacySandbox。
class LegacySandbox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
/** ... 中间实现省略 */
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
delete (this.globalContext as any)[prop];
} else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
(this.globalContext as any)[prop] = value;
}
}
active() {
if (!this.sandboxRunning) {
/** 挂载时设置当前的数据 */
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
/** 卸载时还原之前的数据 */
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
this.sandboxRunning = false;
},
constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.LegacyProxy;
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
const rawWindow = globalContext;
const fakeWindow = Object.create(null) as Window;
const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
if (sync2Window) {
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
}
this.latestSetProp = p;
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
};
const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},
get(_: Window, p: PropertyKey): any {
// 防止使用window.window或者window.self自身访问出现逃逸
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
/** 将value值映射到原window对象上 */
// return rebindTarget2Fn(rawWindow, value);
return value;
},
});
this.proxy = proxy;
}
}
测试代码
const sandbox = new LegacySandbox('测试沙箱', window);
const proxyWindow = sandbox.proxy;
proxyWindow.a = '1';
console.log('挂载:', proxyWindow.a, window.a); // 1 1
sandbox.inactive();
console.log('卸载:', proxyWindow.a, window.a); // undefined undefined
sandbox.active();
console.log('挂载:', proxyWindow.a, window.a); // 1 1
rebindTarget2Fn实现可参考:github.com/umijs/qiank…
单实例下proxy主要是拦截get、set后记录快照,然后通过快照可以恢复原有的场景。
多实例模式
在单实例的场景总,通过fakeWindow一个空的对象,其没有任何储存变量的功能,如果在微应用创建的变量最终实际都是挂载在window上的,这就限制了同一时刻不能有两个激活的微应用。
我们再来看看globalContext,即是之前说的window是如何代理实现的
type FakeWindow = Window & Record<PropertyKey, any>;
const globalContext = window;
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true]
]);
/** 过滤掉不需要绑定的属性 */
const unscopables = {
undefined: true,
Array: true,
Object: true,
String: true,
Boolean: true,
Math: true,
Number: true,
Symbol: true,
parseFloat: true,
Float32Array: true,
isNaN: true,
Infinity: true,
Reflect: true,
Float64Array: true,
Function: true,
Map: true,
NaN: true,
Promise: true,
Proxy: true,
Set: true,
parseInt: true,
requestAnimationFrame: true,
};
function createFakeWindow(globalContext: Window) {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, 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'
) {
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 descriptor 防止被 zone.js 篡改
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
Object.defineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
}
export default class ProxySandbox implements SandBox {
private document = document;
name: string;
proxy: WindowProxy;
sandboxRunning = true;
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
public patchDocument(doc: Document) {
this.document = doc;
}
globalContext: typeof window;
constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
target[p] = value;
}
return true;
}
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get: (target: FakeWindow, p: PropertyKey): any => {
if (p === Symbol.unscopables) return unscopables;
// 防止使用window.window或者window.self自身访问出现逃逸
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}
// hijack globalWindow accessing with globalThis keyword
if (p === 'globalThis') {
return proxy;
}
if (
p === 'top' ||
p === 'parent'
return (globalContext as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
if (p === 'document') {
return document;
}
if (p === 'eval') {
return eval;
}
const value = propertiesWithGetter.has(p)
? (globalContext as any)[p]
: p in target
? (target as any)[p]
: (globalContext as any)[p];
/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
return rebindTarget2Fn(boundTarget, value);
},
});
this.proxy = proxy;
}
}
我们可以看到同时用 Proxy 给子应用运行环境做了 get 与 set 拦截。沙箱在初始构造时建立一个状态池,当应用操作 window 时,赋值通过 set 拦截器将变量写入状态池,而取值也是从状态池中优先寻找对应属性。由于状态池与子应用绑定,那么运行多个子应用,便可以产生多个相互独立的沙箱环境。 测试代码
window.a = 1
const sandbox1 = new ProxySandbox('测试沙箱1', window);
const sandbox2 = new ProxySandbox('测试沙箱2', window);
const proxyWindow = sandbox1.proxy;
const proxyWindow2 = sandbox2.proxy;
proxyWindow.a = 2;
proxyWindow2.a = 5;
console.log('挂载:', proxyWindow.a, window.a); // 2 1
console.log('挂载:', proxyWindow2.a, window.a); // 5 1
sandbox.inactive();
proxyWindow.a = 3;
console.log('卸载:', proxyWindow.a, window.a); // 2 1
sandbox.active();
proxyWindow.a = 4;
console.log('挂载:', proxyWindow.a, window.a); // 4 1
基于属性 diff 的沙箱机制
由于 Proxy 为 ES6 引入的 API,在不支持 ES6 的环境下,我们可以通过一类原始的方式来实现所要的沙箱,即利用普通对象针对 window 属性值构建快照,用于环境的存储与恢复,并在应用卸载时对 window 对象修改做 diff 用于子应用环境的更新保存。在 qiankun 中也有该降级方案,被称为 SnapshotSandbox。当然,这类沙箱同样也不能支持多实例运行,原因也相同。
这类方案的主要思路与 LegacySandbox 有些类似,同样主要分为激活与卸载两个部分的操作。
// iter 为一个遍历对象属性的方法
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
在激活时首先将 window 属性遍历存储起来(作为还原 window 所需的快照),然后在 window 上恢复子应用所需的属性变更,是的,直接修改 window 对象。
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;
}
而等到卸载时,将此时 window 上所包含的属性遍历存储起来(作为以后还原子应用所需的快照),然后从先前保存的 window 对象中将环境恢复。
由于未使用到 Proxy,且只利用 Object 的操作来实现,这个沙箱机制是三类机制中最简单的一种。
SnapshotSandbox 参考代码 github.com/umijs/qiank…*
基于Iframe
利用iframe天然隔离机制,加上postMessage通讯机制,可以快速实现一个简易沙箱,具体步骤如下:
- 创建一个iframe,获取其window作为替代对象
- 将function执行放到iframe里,不会影响其沙箱外程序使用
class SandboxWindow {
constructor(context, frameWindow) {
return new Proxy(frameWindow, {
get(target, name) {
if (name in context) {
return context[name];
} else if(typeof target[name] === 'function' && /^[a-z]/.test(name) ){
return target[name].bind && target[name].bind(target);
} else {
return target[name];
}
},
set(target, name, value) {
if (name in context) {
return context[name] = value;
}
target[name] = value;
}
})
}
}
// 需要全局共享的变量
const context = {
document: window.document,
history: window.history,
location: window.location,
}
// 创建 iframe
const userInputUrl = '';
const iframe = document.createElement('iframe',{url: userInputUrl});
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
// 创建沙箱
const newSandboxWindow = new SandboxWindow(context, sandboxGlobal);
但这个方案有一些限制:
- 阻止
script脚本执行 - 阻止表单提交
- 阻止
ajax请求发送 - 不能使用本地存储,即
localStorage,cookie等 - 不能创建新的弹窗和
window
同时也提供了对应的配置项来解除上述限制。
allow-forms: 允许嵌入的浏览上下文可以提交表单。如果该关键字未使用,该操作将不可用。allow-modals: 允许内嵌浏览环境打开模态窗口。allow-orientation-lock: 允许内嵌浏览环境禁用屏幕朝向锁定(译者注:比如智能手机、平板电脑的水平朝向或垂直朝向)。allow-pointer-lock: 允许内嵌浏览环境使用 Pointer Lock API.allow-popups: 允许弹窗 (类似window.open, target="_blank",showModalDialog)。如果没有设置该属性,相应的功能将静默失效。allow-popups-to-escape-sandbox: 允许沙箱文档打开新窗口,并且不强制要求新窗口设置沙箱标记。例如,这将允许一个第三方的沙箱环境运行广告开启一个登陆页面,新页面不强制受到沙箱相关限制。allow-presentation: 允许嵌入者控制是否iframe启用一个展示会话。allow-same-origin: 允许将内容作为普通来源对待。如果未使用该关键字,嵌入的内容将被视为一个独立的源。allow-scripts: 允许嵌入的浏览上下文运行脚本(但不能window创建弹窗)。如果该关键字未使用,这项操作不可用。allow-top-navigation:嵌入的页面的上下文可以导航(加载)内容到顶级的浏览上下文环境(browsing context)。如果未使用该关键字,这个操作将不可用。
基于 ES 提案 ShadowRealm 实现
ShadowRealm 是一个 ECMAScript 标准提案,旨在创建一个独立的全局环境,它的全局对象包含自己的内建函数与对象(未绑定到全局变量的标准对象,如 Object.prototype 的初始值),有自己独立的作用域,方案当前处于 stage 3 阶段。提案地址 github.com/tc39/propos…
什么是 JavaScript 的运行环境实例
谈及提案之前,我们简单来看看什么是 Realm,下面是 Alex 附上的一个例子:
<body>
<iframe>
</iframe>
<script>
const win = frames[0].window;
console.assert(win.globalThis !== globalThis); // (A)
console.assert(win.Array !== Array); // (B)
</script>
</body>
在前面 iframe 沙箱机制中我们也有介绍,由于每个 iframe 都有一个独立的运行环境,于是在执行时,当前 html 中的全局对象肯定与 iframe的全局对象不相同(A),类似的,全局对象上的 Array与 iframe 中获取到的 Array 也不同(B)。
这就是 realm,一个 JavaScript 运行环境(JavaScript platform)实例:包含其所必须的全局环境及内建函数等。
每个 ShadowRealm 实例都有自己独立的运行环境,它提供了两种方法让我们来执行运行环境中的代码:
.evaluate():同步执行代码字符串,与eval()类似。.importValue():返回一个Promise对象,异步执行代码字符串。
const sr = new ShadowRealm();
console.assert(
sr.evaluate(`'ab' + 'cd'`) === 'abcd'
);
总结
对于上述 Proxy 的两类实现、属性 diff 的一种实现以及 iframe 实现方案,我们可以基于以下三个维度进行对比
| 多实例运行 | 语法兼容 | 不污染全局环境(主应用) | |
|---|---|---|---|
| LegacySanbox | ❌ | ❌ | ❌ |
| ProxySandbox | ✅ | ❌ | ✅ |
| SnapshotSandbox | ❌ | ✅ | ❌ |
| iframe | ✅ | ✅ | ✅ |
其他几种由于存在不同的缺点或兼容性,暂时不推荐在生产环境使用
本文基于个人项目实践,阅读代码梳理等方式对每类沙箱机制均进行了介绍,部分引用了 qiankun 的代码实现,部分写了伪代码解释,部分引用了最新 ECMAScript 提案示例,但未进行详细讲解,如果你想了解关于 CSS 样式隔离的内容可以搜索 Shadow DOM 相关内容进一步查阅;如果你想了解微前端的主子应用加载、运行机制,可以参考 single-spa 文档、qiankun 文档、ShadowRealm 提案等内容;如果你想了解文中涉及的一些概念与 API 用法可以在 MDN 进行搜索查阅,大部分均有对应介绍。