微前端是如何实现作用域隔离的?
一、前言
沙箱(Sandbox)是一种安全机制,目的是让程序运行在一个相对独立的隔离环境,使其不对外界的程序造成影响,保障系统的安全。作为开发人员,我们经常会同沙箱环境打交道,例如,服务器中使用 Docker 创建应用容器;使用 Codesandbox运行 Demo示例;在程序中创建沙箱执行动态脚本等。
二、使用场景
2.1 iPaaS 可视化 API 编排
在流程编排的某些节点需要用到低代码模型转换(Transformer),用户可在转换器流程节点自定义 Groovy 脚本实现,服务端在执行自定义的 Groovy 脚本时,会放置在沙箱中,避免对整个流程逻辑造成影响。
2.2 微前端应用沙箱
在微前端当中,有一些全局对象在所有的应用中需要共享,如 Window 对象。不同开发团队的子应用很难通过规范约束他们使用全局变量。为了保证应用的可靠性,需要技术手段去治理运行时的冲突问题;通过使用沙箱,每个前端应用都可以拥有自己的上下文环境、页面路由和状态管理,而不会相互干扰或冲突。
接下来的篇章我们将介绍大前端领域沙箱的实现以及我们如何基于JS沙箱落地应用的过程。
三、JS沙箱调研
3.1 eval和Function
前端常见的动态执行代码的方式是使用 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()}"
))
两种方式都可以正常执行,并且返回结果相同,但是用来创建沙箱环境还不够格,因为它们都能访问[全局变量],无法实现作用域隔离。
3.2 with + new Function + proxy实现
3.2.1 with关键字
JavaScript 在查找某个未使用命名空间的变量时,会通过作用于链来查找,而 with 关键字,可以使得查找时,先从该对象的属性开始查找,若该对象没有要查找的属性,顺着上一级作用域链查找,若不存在要查到的属性,则会返回 ReferenceError 异常。
不推荐使用 with,在 ECMAScript 5 严格模式中该标签已被禁止。推荐的替代方案是声明一个临时变量来承载你所需要的属性。
3.2.2 ES6 Proxy
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
3.2.3 Symbol.unScopables
With 再加上 Proxy 几乎完美解决 JS 沙箱机制。但是如果对象的Symbol.unScopables设置为 true ,会无视 with 的作用域直接向上查找,造成沙箱逃逸,所以要另外处理 Symbol.unScopables。
3.2.4 沙箱实现
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件事,实现沙箱隔离:
- 使用 with API,将对象添加到作用域链的顶部,变量访问会优先查找你传入的参数对象,之后再往上找;
- 通过ES6提供的proxy,设置has函数,实现对象的访问拦截,同时处理Symbol.unscopables 的属性,控制可以被访问的变量 context,阻断沙箱内的对外访问;
- 绑定 this 指向 proxy 对象,防止 this 访问 window;
3.3 基于iframe实现
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;
3.4 node运行时实现
3.4.1 原生模块vm
相较于浏览器环境,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); // 17console.log(x); // 1; y is not defined.
问题来了,使用 vm.runInContext 看似创建了沙箱隔离环境,但 vm 模块足够安全吗?引用 Node 官网的回答
node:vm 模块不是安全机制。不要用它来运行不受信任的代码。
3.4.2 不安全原因
为什么不是安全机制,继续剖析;
const vm = require('vm');
vm.runInNewContext('this.constructor.constructor("return process")().exit()');
console.log('智慧商业服务商') // 永远不会执行
这就是 JS 语言的特性,以上示例中 runInNewContext 会默认创建上下文对象, this 指向默认创建的 ctx 对象 并通过原型链的方式拿到沙盒外的 Funtion,通过Function 访问全局变量,完成逃逸,并执行逃逸后的 JS 代码。
3.4.3 解决方案
解决方案是绑定上下文对象,同时切断上下文对象的原型链,提供纯净的上下文对象,避免通过原型链逃逸。
const vm = require('vm');
let sandBox = Object.create(null);
sandBox.title = '智慧商业服务商'
sandBox.console = console
vm.runInNewContext('console.log(title)', sandBox);