一、前端JS沙箱
在很多比较复杂的应用场景中,我们需要运行一些“不受信任” 的JavaScript代码,包含但不局限于下面案例:
-
在线编辑器、OnlineJudge等, 运行用户自定义的脚本代码
-
某些不受信任的第三方提供的库、工具等
-
微前端 场景,加载接入方提供的代码
如果完全不考虑沙箱机制,可能会有下面的问题出现:
-
第三方代码享受同源策略,可能窃取主站的用户信息等等
-
各种全局变量混用,导致页面的状态极其混乱
-
代码意外崩溃,直接导致主页面跟着崩溃
由于浏览器目前并没有“完美”地提供一个彻底且功能强大的沙箱方案,因此我总结了一些市面上常见的沙箱方案,供大家学习参考。
二、沙箱方案总结
-
iFrame
方案说明
iframe是浏览器“天生”自带,最“彻底”的隔离方案。
我们创建一个iframe:
<iframe src="about:blank" />
这里标记about:blank可以避免额外请求的开销,我们主要关注它的这个属性
iframe.contentWindow
相当于,iframe为我们生成了一个新的window对象,且由于是同域名,我们可以任意操作这个window对象(以及document等等)。小到W3School的案例,大到JSFiddle或者CodePen都是使用Iframe的策略,区别在于直接操作contentWindow还是通过后端服务渲染好HTML再返回。相比之下后者更重但是更安全一些,因为存在域名隔离,即使随便分享出去问题也不大。
优劣分析
| 优势 | 1. 浏览器自带,无需考虑兼容性,使用起来相对简便
-
最彻底的隔离手段,单独占用一个进程,与主进程里的内容完全隔离
-
适合前端单独项目级别的沙盒环境演示 | | -- | ----------------------------------------------------------------------------------- | | 劣势 | 1. iframe带来的体验割裂感,适用面狭窄
-
需要浏览器开辟新进程、新运行时、新请求等,带来额外性能开销
-
不利于SEO,对大型产品非常不友好 |
-
Snapshot
方案说明
这是早期“微前端”框架衍生出的方案,核心思路是,在子应用挂载前,把当前window全局变量打一份快照并把window传递给子应用,在子应用卸载后,再从快照恢复。
最简单的代码流程如下:
class Sandbox {
snapshot = new Map();
onMount() {
this.snapshot.clear();
// 缓存当前window数据到snapshot中
for (const prop in window) {
this.snapshot.set(prop, window[prop]);
}
// 运行/挂载子应用
this.mountMircoApp();
}
onUnmount() {
// 恢复变更
for (const prop in window) {
window[prop] = this.snapshot.get(prop);
}
// 停止/卸载子应用
this.unmountMircoApp();
}
}
优劣分析
| 优势 | 1. 容易实现,浏览器支持程度最好
-
相对能够保证全局状态在之前和之后的一致性 | | -- | -------------------------------------------------------------------------------------------------------------------------- | | 劣势 | 1. 拷贝window的对象成本很大,不适合需要频繁调用的场景
-
仅可支持同时只有一个子应用(沙箱)的运行,因为window仍然是全局只有一个
-
快照的方式不够严格,保存引用的话深度对象会遗漏;深度拷贝时会有循环引用、引用丢失等等 |
-
Proxy
方案说明
这是大部分当代“微前端”框架都会采用的方案,原理是提供一个“假的”window对象给子应用使用。
原理大致如下:
const sandbox = new Function(`
return function(window, code) {
with(window) {
${code}
}
}
`);
sandbox().call(window, code);
这里使用Function构造函数生成一个动态表达式,利用闭包传入window和code参数,利用with特性可以让code里面代码的window对象都变成闭包传入的window。
这是基本思路,不过传入的window仍然是主页面的window,需要修改一下。一种比较好的方案,就是结合上面提到的**iframe.contentWindow方案,用Proxy**代理这个window对象并传入沙盒。
实现起来的话,大致如下
function createFakeWindow() {
// 也可以使用一个空对象{}即可
const iframeWindow = iframe.contentWindow;
const realWindow = window;
// 代理iframe window对象
const fakeWindow = new Proxy(iframeWindow, {
// 这里可以自由定义拦截策略
get(target, prop) {
// 如果本身已经存在主进程上的
if (prop in realWindow) {
return realWindow[prop];
}
// 否则返回fakewindow对象属性
return target[prop];
},
set(target, prop, value) {
// 设置时,可以只挂到fakewindow上
target[prop] = value;
return true;
}
});
return fakeWindow;
}
这样可以保证子应用可以复用主应用已经定义好的全局对象,子应用修改的数据只会挂载到自己的闭包上,不存在互相污染的情况。
优劣分析
| 优势 | 1. 几乎是最理想的微前端隔离方案,同样也适合于自定义代码运行场景
-
支持多个子实例同时运行,不用考虑数据互相污染的情况
-
可以一定程度的实现主应用和子应用数据互通 | | -- | ----------------------------------------------------------------------------------------- | | 劣势 | 1. 需要浏览器兼容Proxy API
-
由于是同域调用,可能会存在一定的安全问题
-
只要用到了with/Proxy,多多少少都会有点性能问题 |
-
Web Worker
方案说明
Web Worker子线程是天然的一个沙箱方案,它无法访问DOM相关的数据,没有全局变量,不能执行alert和confirm方法,不能读取文件等等。
Worker和主线程唯一的方式就是postmessage,采用“发送” -> “接受”的消息通信模式。也就是说采用Webworker方案的话,我们可以把需要执行的代码通过消息发送给Worker,这其中也包含一些需要拷贝的参数,Worker计算完毕后再返回,流程上会是一个异步行为。
值得一提的是,Worker还提供了importScripts来引入额外的脚本,这个方法是同步执行,可以引入外部的脚本并运行,脚本如果挂载了全局变量,在Worker中可以直接获取到。
MDN官方提供了一个异步eval()的例子:developer.mozilla.org/zh-CN/docs/…
优劣分析
| 优势 | 1. 浏览器自带的原生隔离方案,还支持嵌入式以及动态引入脚本,封装起来不复杂
-
后台运行,对主线程无影响,对于需要纯计算的场景非常合适
-
支持大部分通用的API,如setTimeout/setInterval等等 | | -- | ----------------------------------------------------------------------------------------------------------------- | | 劣势 | 1. 通信的数据采用拷贝模式,对于庞大参数会有一定的开销
-
异步调用,需要注意处理时机
-
不适合频繁操作DOM类的行为 |
-
SES
方案说明
SES是Security EcmaScript的缩写,是一个更严格的JavaScript特性,可以控制各个作用域下JavaScript具有的特性,我们重点看一下沙盒特性。
SES提供了一个Compartment构造函数,它接收一个入参globalThis,这会生成一个以globalThis作为全局对象(相当于隐式的window)的完全隔离的沙箱环境,不过window上自带的一些常用对象也可共享使用。
需要执行的话,只需要调用evaluate(code)方法即可
const compartment = new Compartment({ log: console.log });
compartment.evaluate(`log(123)`);
SES目前还处于Stage 1的阶段,提案参考:github.com/tc39/propos…
目前已经有JavaScript提供shim,github.com/endojs/endo…
优劣分析
| 优势 | 1. 提供简便,容易上手的API
-
可以扩展导入和导出方法,搭配
hardened等功能,提供功能更强的沙箱环境 | | -- | ----------------------------------------------------------------- | | 劣势 | 1. 目前仍然处于早期提案,相关建设处于早期状态,文档不够完备 -
shim带来的体积较大,无法按需加载,不是稳定的解决方案 |
-
ShadowRealm
方案说明
ShadowRealm可能是下一代浏览器原生能提供最好的沙箱方案了。它一共只有三个方法
declare class ShadowRealm {
constructor();
evaluate(sourceText: string): PrimitiveValueOrCallable;
importValue(specifier: string, bindingName: string): Promise<PrimitiveValueOrCallable>;
}
ShadowRealm没有构造参数,创建成功后会产生一个内置独立的上下文对象globalThis,这个globalThis在evaluate方法中,可以作为全局对象使用,比如
const sr = new ShadowRealm();
sr.evaluate('globalThis.title = "Hello World"');
// 也可以用来定义可执行的function
const func = sr.evaluate('() => globalThis.title');
func() === 'Hello World'; // true
还有一个方法importValue可以异步导入模块
// 导入module模块导出的sum方法
const sum = await sr.importValue('./module.js', 'sum');
sum(1, 2); // 正常调用即可
ShadowRealm提供了一个高度可编程的沙盒环境,无论是在微前端、编辑器等场景,都是非常简单易用,功能性强的理想方案。
ShadowRealm目前进入Stage 2阶段,提案参考:github.com/tc39/propos…
不过目前难以被shim完美的实现,因此想使用上还需要等一段时间。
优劣分析
| 优势 | 1. 提供功能强大,扩展性强,简单易用的API
-
完全的上下文隔离,对ES Module的良好支持 | | -- | ----------------------------------------------------- | | 劣势 | 1. 需要等待进入Stage 4以及浏览器的实现,兼容性较差
-
很多高级功能需要进一步封装 |
-
AST Parser
方案说明
基于AST Parser,对用户输入的字符串语句进行分析,排查潜在可能出现问题的地方并执行,相当于基于浏览器JavaScript解释器上层又构造了一个解释器子集,这也是一种可行的办法。
不过不同解释器支持的程度是不同的,比如
-
github.com/silentmatt/… 只能运行单一表达式
-
github.com/NeilFraser/… 功能更强,文档较旧
-
github.com/bplok20010/… 类似,使用TypeScript实现
-
github.com/sablejs/sab… 自行定义了一套语法API
优劣分析
| 优势 | 1. 基于JS AST实现,基本不存在兼容性问题
-
使用起来比较方便,一般都是同步执行 | | -- | -------------------------------------------------------------------------------------------------- | | 劣势 | 1. 依赖AST解析和对应库实现程度,稳定性、安全性不能确保一定达标
-
难以利用JIT等浏览器自带编译优势,提高代码执行速度
-
可能对很多扩展性的API支持不太好,不能支持动态引入包 |
-
JavaScript in WebAssembly
方案说明
WebAssembly又是一个天然优质的沙箱方案,它的运行内存与外界完全隔离,只能进行数据计算,无法操作DOM,同时又可以提供一定程度上更高效的计算性能。
我们现在讨论的是JavaScript沙箱,那么如何借助WebAssembly来运行呢?核心思想仍然是采用上面的构建AST的思路,只不过这次我们可以把AST解释器编译进WebAssembly中。
QuickJS是一个用C编写的JavaScript运行引擎,理论上是可以通过emscripten编译成WebAssembly格式,事实上已经有人连着JS Bindings一起帮我们做好了 github.com/justjake/qu…
官方网站已经给出了示例,这里简单介绍下:
-
引入node运行时
- 由于这个工具自身并不是为了浏览器端设计,引入很多node模块,需要在webpack中添加polyfill
-
resolve: { fallback: { "path": require.resolve("path-browserify"), "fs": require.resolve("browserify-fs"), "stream": require.resolve("stream-browserify"), "buffer": require.resolve("buffer-browserify"), "util": require.resolve("util/"), "assert": require.resolve("assert/") } }
-
获取QuickJS 虚拟机
import { getQuickJS } from "quickjs-emscripten"
const wasm = await getQuickJS();
const vm = wasm.newContext();
- 生成全局变量
// 创建一个"world" 字符串
const world = vm.newString("world");
// 在全局,设置一个名字叫 NAME 的变量,值为上面的字符串
vm.setProp(vm.global, "NAME", world);
// 用完了需要及时释放内存
world.dispose();
- 运行代码
const { error, value } = vm.evalCode(`"Hello " + NAME + "!"`);
if (error) {
// 错误处理
} else {
// 需要通过vm.dump把wasm的结果转为JavaScript变量
console.log("Success:", vm.dump(value))
// 使用完毕后,需要及时销毁
value.dispose();
}
- 销毁虚拟机实例
vm.dispose();
由于WebAssembly机制运行的原因,我们必须妥善处理各个内存分配,使用起来有一定复杂度。
Figma曾经考虑过使用github.com/svaarala/du…
优劣分析
| 优势 | 1. 天然的沙箱隔离优势,不用担心数据的杂乱
-
可以复用各种强大的本地解释器 | | -- | ----------------------------------------------------------------------------------------- | | 劣势 | 1. 需要理解WebAssembly对应的API,有使用成本
-
有很多局限性,取决于解释器的实现程度
-
需要引入额外的WebAssembly文件,运行效率未必有优势 |
-
Async Import
方案说明
严格来说这并不是一个沙箱方案,更偏向于“动态加载”这一块。
原理是基于这个提案:github.com/tc39/propos… Module以后可能会成为主流,这里也顺便分享下。
比如一份自定义代码a.js
export default class Component {}
使用的时候,注意需要和webpack自身的语法区分开:
const Component = (await import(/* webpackIgnore: true */'./a.js')).default;
const component = new Component();
如果全部模块都能做到ES Module化的话,这样看结构会非常清晰。
优劣分析
| 优势 | 1. 适合需要异步加载的受信任的插件、脚本代码等场景。
-
大部分浏览器都支持此特性,JS运行时无限制,性能最高 | | -- | ----------------------------------------------------------- | | 劣势 | 1. 没有提供沙箱机制,需要人为限制
-
需要对项目做一些改造,以配合ES Module的使用 |
-
NodeJS VM
方案说明
这并不是浏览器端沙箱的解决方案,但是却可以辅助解决一些应用场景问题。
Nodejs自带了vm模块,可以提供一个隔离的上下文环境,比eval的安全性要高很多,不过由于共享主进程,有可能会有逃逸的情况,参考这篇文章。
因此现在一般都用 github.com/patriksimek… 这个模块,几乎不存在逃逸的漏洞,不过JavaScript实在太灵活了,还时不时有一些避免不了的情况。
因此运行VM2适合作为一个单独的服务存在,尽量在物理上保证其隔离性。
优劣分析
| 优势 | 1. 利用NodeJS原生模块实现,使用方便高效
-
完全不影响浏览器端的状态 | | -- | ----------------------------------------------------------------- | | 劣势 | 1. 需要后端提供接口运行,添加了额外的依赖,需要保证稳定性
-
不适合需要频繁运行的场景,不适合大对象,也不支持DOM操作 |
三、一点总结
目前看来,最理想的沙盒方案应该就是ShadowRealm了,鉴于其兼容性很差,当下一般需要根据自己项目的需求选择合适的方案。
我把自己的想法大概总结下,欢迎大家积极和我讨论:
- Proxy(with iframe) 可能是当下最好的,避免全局变量混淆以及一些相对安全的沙箱环境,微前端和插件式都可以用。
- Web Worker/WebAssembly/NodeJS VM都是异步进行的,安全性越来越高,需要看具体的场景能否接受异步函数
- AST Parser 适用于一些简单场景的计算,比如“动态表单渲染”表达式的计算
- Async Import 适用于不考虑沙箱机制,只考虑引入受信任动态脚本的场景,因为就是当做JavaScript代码运行,效率是最高的
- SES因为提案进度delay不推荐使用,snapshot限制多、成本大也不推荐使用。