沙箱(Sandbox)是一种安全机制,目的是让程序运行在一个相对独立的隔离环境,使其不对外界的程序造成影响,保障系统的安全。作为开发人员,我们经常会同沙箱环境打交道,例如,服务器中使用 Docker 创建应用容器;使用 Codesandbox运行 Demo示例;在程序中创建沙箱执行动态脚本等。
在流程编排的某些节点需要用到低代码模型转换(Transformer),用户可在转换器流程节点自定义 Groovy 脚本实现,服务端在执行自定义的 Groovy 脚本时,会放置在沙箱中,避免对整个流程逻辑造成影响。
在微前端当中,有一些全局对象在所有的应用中需要共享,如 Window 对象。不同开发团队的子应用很难通过规范约束他们使用全局变量。为了保证应用的可靠性,需要技术手段去治理运行时的冲突问题;通过使用沙箱,每个前端应用都可以拥有自己的上下文环境、页面路由和状态管理,而不会相互干扰或冲突。
接下来的篇章我们将介绍大前端领域沙箱的实现以及我们如何基于JS沙箱落地应用的过程。
前端常见的动态执行代码的方式是使用 Eval 和 New Function 提供一个运行外部代码的环境:
- // 使用 eval 的糟糕代码:
- function looseJsonParse(obj){
- return eval(`(${obj})`);
- }
- console.log(looseJsonParse(
- "{a:(4-1), b:function(){}, c:new Date()}"
- ))
-
- // 使用 Function 的更好的代码:
- function looseJsonParse(obj){
- return Function(`"use strict";return (${obj})`)();
- }
- console.log(looseJsonParse(
- "{a:(4-1), b:function(){}, c:new Date()}"
- ))
-
两种方式都可以正常执行,并且返回结果相同,但是用来创建沙箱环境还不够格,因为它们都能访问[全局变量],无法实现作用域隔离。
JavaScript 在查找某个未使用命名空间的变量时,会通过作用于链来查找,而 with 关键字,可以使得查找时,先从该对象的属性开始查找,若该对象没有要查找的属性,顺着上一级作用域链查找,若不存在要查到的属性,则会返回 ReferenceError 异常。
不推荐使用 with,在 ECMAScript 5 严格模式中该标签已被禁止。推荐的替代方案是声明一个临时变量来承载你所需要的属性。
Proxy 是 ES6 提供的新语法,Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。示例如下:
- const handler = {
- get: function (obj, prop) {
- return prop in obj ? obj[prop] : 'weimob';
- },
- };
-
- const p = new Proxy({}, handler);
- p.a = 2023;
- p.b = undefined;
-
- console.log(p.a, p.b); // 2023 undefined
- console.log('c' in p, p.c); // false, weimob
With 再加上 Proxy 几乎完美解决 JS 沙箱机制。但是如果对象的Symbol.unScopables设置为 true ,会无视 with 的作用域直接向上查找,造成沙箱逃逸,所以要另外处理 Symbol.unScopables。
- function sandbox(code, context) {
- context = context || Object.create(null);
- const fn = new Function('context', `with(context){return (${code})}`);
- const proxy = new Proxy(context, {
- has(target, key) {
- if (["console", "setTimeout", "Date"].includes(key)) {
- return true
- }
- if (!target.hasOwnProperty(key)) {
- throw new Error(`Illegal operation for key ${key}`)
- }
- return target[key]
- },
- get(target, key, receiver) {
- if (key === Symbol.unscopables) {
- return undefined;
- }
- return Reflect.get(target, key, receiver);
- }
- })
- return fn.call(proxy, proxy);
- }
-
- sandbox('3+2') // 5
- sandbox('console.log("智慧商业服务商")') // Cannot read property 'log' of undefined
- sandbox('console.log("智慧商业服务商")', {console: window.console}) // 智慧商业服务商
-
上面的代码主要做了3件事,实现沙箱隔离:
iframe 标签可以创造一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现了与主环境的隔离。在 iframe 中运行的脚本程序访问到的全局对象均是当前 iframe 执行上下文提供的,不会影响其父页面的主体功能,因此使用 iframe 来实现一个沙箱是目前最方便、简单、安全的方法。
- const parent = window;
- const frame = document.createElement('iframe');
- // 限制代码 iframe 代码执行能力
- frame.sandbox = 'allow-same-origin';
- document.body.appendChild(iframe);
- const sandboxGlobal = iframe.contentWindow;
相较于浏览器环境,Node运行时就简单很多,使用其提供的原生vm模块,可以很方便的创建V8虚拟机,并在指定上下文编译和执行代码;
- const vm = require('node:vm');
-
- const x = 1;
-
- const context = { x: 2 };
- vm.createContext(context); // Contextify the object.
-
- const code = 'x += 40; var y = 17;';
- vm.runInContext(code, context);
-
- console.log(context.x); // 42
- console.log(context.y); // 17
-
- console.log(x); // 1; y is not defined.
-
问题来了,使用 vm.runInContext 看似创建了沙箱隔离环境,但 vm 模块足够安全吗?引用 Node 官网的回答
node:vm 模块不是安全机制。不要用它来运行不受信任的代码。
为什么不是安全机制,继续剖析;
- const vm = require('vm');
- vm.runInNewContext('this.constructor.constructor("return process")().exit()');
- console.log('智慧商业服务商') // 永远不会执行
这就是 JS 语言的特性,以上示例中 runInNewContext 会默认创建上下文对象, this 指向默认创建的 ctx 对象 并通过原型链的方式拿到沙盒外的 Funtion,通过Function 访问全局变量,完成逃逸,并执行逃逸后的 JS 代码。
解决方案是绑定上下文对象,同时切断上下文对象的原型链,提供纯净的上下文对象,避免通过原型链逃逸。
- const vm = require('vm');
- let sandBox = Object.create(null);
- sandBox.title = '智慧商业服务商'
- sandBox.console = console
- vm.runInNewContext('console.log(title)', sandBox);