本文首发自 微盟技术中心 微信公众平台~
一、前言
沙箱(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 undefinedconsole.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') // 5sandbox('console.log("微盟-智慧商业服务商")') // Cannot read property 'log' of undefinedsandbox('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); // 42console.log(context.y); // 17
console.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 = consolevm.runInNewContext('console.log(title)', sandBox);
四、落地实践
有了上面调研的理论基础,接下来介绍在实际的业务场景中,我们如何在应用中落地 JS 沙箱环境。
4.1 微盟微前端框架Kraken
微前端沙箱是一种用于实现微前端架构的关键技术之一。它提供了一种方式,使得在一个单页面应用中可以同时运行多个独立的前端应用,而这些前端应用可以由不同的团队开发和维护。微前端沙箱的主要目标是隔离不同的前端应用,以保证它们之间的相互独立性和安全性。通过使用沙箱,每个前端应用都可以拥有自己的上下文环境、页面路由和状态管理,而不会相互干扰或冲突。微前端沙箱通常包含以下核心功能:模块隔离: 通过使用 JavaScript 模块加载器,每个前端应用可以将其代码和依赖项封装在私有的命名空间中,避免全局变量污染和冲突。样式隔离: 通过使用 CSS 命名空间或 CSS-in-JS 等技术,确保每个前端应用的样式只对其自身生效,避免样式冲突和覆盖。路由隔离: 每个前端应用可以拥有自己的路由管理器,管理其内部的页面跳转和导航。状态隔离: 通过使用独立的状态管理库,每个前端应用可以维护自己的状态,并确保状态在不同应用之间不会互相干扰。安全性: 微前端沙箱可以采取各种安全措施,例如限制前端应用的权限、验证加载的模块和资源的完整性等,以防止潜在的安全漏洞和攻击。Kraken 是微盟自研的微前端框架,支撑公司大部分中后台应用。在 Kraken 框架中,我们深度运用沙箱,构建框架基础能力,包括数据共享、通信等。Kraken 沙箱,是通过 Fakewindow + Proxy 实现,整体流程如下:
4.1.1 JS 隔离
创建沙箱实例,声明全局的 API,主要有addEventListener,setTimeout,createElement等;同时拦截对沙箱实例属性的访问,提供沙箱的生命钩子;
通过with,限制 JS 执行上下文内的作用域;
- 动态解析JS,使用 Function,解析字符串,通过 ES6 提供的 proxy,设置 has 函数,实现对象的访问拦截,同时套 SandBox 实例沙箱;
4.1.2 CSS 隔离
1、CSS 隔离采取 css module + 命名空间;css module 是构建的时候对齐样式增加hash,具体不再赘述;命名空间,在项目构建的时候,约定css增加以项目包名为命名空间;2、Dom隔离:在初始化沙箱的时候,对 appendChild 等,做拦截;对子应用的资源,比如 CSS 等 Dom 单独做隔离;
4.1.3 数据共享与通信
在实际的使用场景中,沙箱不但要提供运行隔离环境,还要支持数据共享;很多的时候,每个应用并不是严格独立运行,需要共享一部分数据做业务逻辑;这就要求,沙箱不能完全隔离,还要提供共享和通信能力;a、数据共享微前端 kraken 针对这种场景,设计 kraken.data 对外暴露,提供可读写的 API;下面介绍一些主要的实现方式:b、事件通信严格意义上讲,事件通信不算沙箱本身能力,算是补充沙箱本身业务能力缺陷,通过发布订阅者模式实现;从微前端应用结构看,支持父子,兄弟,以及全局通信;从事件类型看,支持同步和异步两种事件;
4.2 微盟中心化审批服务**
为保障公司系统安全平稳运行,减少生产事故发生,增强全员生产履职尽责意识,公司制定了较为完善的《生产变更管理规范》;规范要求:所有存在生产变更的系统,均需参照说明,定义符合自身系统的变更对象、变更操作风险、审批流,各个系统需要针对性完成系统化改造;总结归纳就是内部所有系统针对生产环境的变更操作,要做审批定级,接入公司统一的审批流,上级 Leader 审批后方可执行,同时要求变更审批人要认真审批,出了问题审批节点责任人要负责;微盟存在大大小小无数个业务系统,如果每个系统都独自对接审批流,成本巨大;针对这种情况,业务中台在微前端 Node 网关之上实现了中心化审批服务,各业务系统可以通过配置变更功能点接口的方式无代码接入审批流,实现合规变更,整体流程如下:
4.2.1 node审批工单服务
上图可以发现,内部统一平台的网关层是 Node 开发的,实现了接口代理转发(解决跨域)、鉴权、路由、限流等基础能力;基于这一天然优势,在网关层拦截API并发起审批工单非常地方便,工单创建流程如下: 整体流程非常清晰,平台功能大方向确定;但是业务方的需求是多样化、差异化的,这个时候有业务方提出了建议"希望可以丰富工单详情,提供精确的变更说明信息";需求很合理也很明确,针对此需求收集了众多反馈意见,最终达成一致,由业务方通过脚本自行扩展,平台提供脚本执行能力!
4.2.2 动态扩展脚本
既然是扩展工单详情,审批服务在指定的声明周期(工单创建前)设计了钩子函数,业务方登记时,可一并登记扩展脚本,通过钩子函数执行,实现丰富工单详情的目的;当然了,为了安全,我们同样使用JS沙箱环境执行业务登记的脚本。
这里的沙箱环境是在node运行时创建,基于我们的调研,直接使用node提供的原生vm模块;核心代码如下:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
/** * 执行用户传入的字符串脚本 * @param {String} code js脚本字符串 * @param {Object} context code中可读取的上下文 * vm.runInNewContext(code[, contextObject[, options]]) */async function execScript(code, context) { try { const contextObject = Object.assign(context, { setTimeout, request }) return vm.runInNewContext(code, contextObject) } catch (error) { return Promise.reject(error) }}
用户自定义的扩展脚本如下:
-
-
(() => ({ objectAction: `发布online:${packageName}@${packageVersion}` }))()
看起来很简单,使用 vm.runInNewContext 创建隔离环境,并传入指定的 context 上下文,包括请求体等, 在此上下文中执行用户的扩展脚本,最后返回执行结果,实现动态扩展。
4.2.3 总结
读到这里的同学可能会问”调研时不是说过,node:vm 模块不是安全机制。不要用它来运行不受信任的代码“,是的,有两个原因让我们依然选择使用vm模块;
1、我们的node审批服务只服务内部用户,在内网运行,风险可控;
2、用户登记变更对象和扩展脚本后,我们加入了审核机制,人工审核后放行;
总体来说,在node端实现沙箱环境较为简单,但是既要灵活可控,又要保障系统安全,做一定的约束必不可少!
五、总结展望
本文主要介绍了沙箱的基本概念、应用场景以及大前端领域如何实现 Javascript 沙箱。通过在公司内部落地应用,可以发现沙箱的实现方式并不是一成不变的,应当结合具体的业务场景分析其需要达成的目标。
Kkraken微前端框架目前服务公司内部运营后台、微盟开发平台、WOS商家后台、微盟云平台等多个业务线,为数百个微前端应用提供基座运行环境,所以在框架设计时就充分考虑了各种安全问题,沙箱逃逸的防范是一件任重而道远的事,业务场景的多元化有助于我们覆盖更多地执行 case,逐渐打磨,日趋完美。
中心化审批服务是公司内部的工具类应用,服务于内网用户,设计实现时考虑更多的是快速实现,快速落地,覆盖基本的执行case就可以;目前对用户提交脚本有人工审核,安全有保障,对比业务方独立对接审批流,提效95%以上,极大降低了接入成本。为"保障公司系统安全平稳运行,减少生产事故发生"提供了强有力的技术支持。同时我们也在优化对接流程,提升业务方的接入体验,为前端团队创造更大的价值,为公司研发投入降本增效。
六、参考资料
(1) vm 虚拟机 | Node.js v20.4.0 文档(https://nodejs.cn/api/vm.html)(2)eval() - JavaScript | MDN(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Refer...)(3)说说JS中的沙箱(https://juejin.cn/post/6844903954074058760)
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。