Sandbox 沙箱
Sandbox 沙箱
在现实与我们前端相关的场景中,我们平时使用的浏览器就是一个沙箱,运行在浏览器中的 JavaScript 代码无法直接访问文件系统、显示器或其他任何硬件。Chrome 浏览器中每个标签页也是一个沙箱,各个标签页内的数据无法直接相互影响,接口都在独立的上下文中运行。
在微前端架构是在同一个浏览器tab下运行,沙箱隔离需要解决如下2个问题:
- 各个子应用间的调度实现以及其运行态的维护
- 挂在 window 上的全局方法/变量(如 setTimeout、滚动等全局事件监听等)在子应用切换时的清理和还原。
常见的沙箱实现有两种:Proxy代理沙箱(ProxySandBox )、快照沙箱(SnapshotSandBox)。ProxySandBox是基于 Proxy API 来实现的,在不支持 Proxy的低版本浏览器中,可以降级为 SnapshotSandBox。对比一下:
Proxy代理沙箱 |
IE 不支持Proxy |
可支持多实例场景 |
快照沙箱 |
ALL |
仅单实例场景 |
多实例场景:一般我们的中后台系统同一时间只会加载一个子应用的运行时。但是也存在这样的场景,某一个子应用聚合了多个业务域,这样的子应用往往会经历多个团队的多个同学共同维护自己的业务模块,这时候便可以采用多实例的模式聚合子模块(这种模式也可以叫微模块)
Proxy代理沙箱(ProxySandBox )
Proxy代理沙箱(ProxySandBox )是基于Proxy API来实现的
Proxy介绍
Proxy API
Proxy demo
假设我们有一个数据(对象)data,现在我们给 data 创建一个代理 proxy
let data = { username: 'Frank', age: 26 } let proxy = new Proxy(data, {set: function(){...}, get: function(){...} })
此时,「proxy 就全权代理 data了」,意思就是 data 放假去了,如果你有任何事情要找 data,直接找 proxy 就好了,proxy 现在是 data 的秘书、代理人。比如原本你如果要改 username,那么应该写 data.username = 'frank';那么现在你只需要写 proxy.username = 'frank' 就好了。原本你如果想写 console.log(data.username),现在也只需要 console.log(proxy.username) 就可以了。
那么我们思考一下这样做有什么意义呢?意义就是能监控每一次对 data 的读写操作。
proxy.username = 'frank' 这句话实际上会运行 set 方法。set 方法可以对传入的值进行监控和过滤。假设 PD 要求「username 前后不能含有空格」,用 Proxy 就很好实现这一需求,只需要把 set 写成这样:
set: function(obj, prop, value){ obj[prop] = value.trim() }
再假设 PD 要求「统计 username 被读取的次数」,那么我们只需要把 get 写成这样:
get: function(obj, prop){ if(prop === 'username'){ count += 1 } return obj[prop] }
简单实现一个Proxy 代理沙箱
每个应用都创建一个proxy来代理window对象,好处是每个应用都是相对独立的,不需要直接更改全局的window属性。
class ProxySandbox { constructor() { const originWindow = window; const proxyWindow = {} const proxy = new Proxy(proxyWindow, { set(target, p, value) { target[p] = value; return true }, get(target, p) { return target[p] || originWindow[p]; } }); this.proxy = proxy } } let sandbox1 = new ProxySandbox(); let sandbox2 = new ProxySandbox(); window.a = 1; ((window) => { window.a = 'hello'; console.log('sb1 window',window) console.log(window.a) })(sandbox1.proxy); ((window) => { window.a = 'world'; console.log('sb2 window',window) console.log(window.a) })(sandbox2.proxy);
快照沙箱(SnapshotSandBox)
激活时将当前window属性进行快照处理,失活时用快照中的内容和当前window属性比对,如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性。再次激活时,再次进行快照,并用上次修改的结果还原window属性。快照沙箱只能针对单实例应用场景,如果是多个实例同时挂载的情况则无法解决
class SnapshotSandbox { constructor() { this.proxy = window; //浏览器window快照 this.originalSnapshot = {}; //运行时属性的变化录入 this.modifyPropsMap = {}; } active() { //激活时将当前window属性进行快照处理 for (const prop in window) { if (window.hasOwnProperty(prop)) { this.originalSnapshot[prop] = window[prop]; } } Object.keys(this.modifyPropsMap).forEach(prop => { window[prop] = this.modifyPropsMap[prop]; }); } //失活时用快照中的内容和当前window属性比对, //如果属性发生变化保存到modifyPropsMap中,并用快照还原window属性 inActive() { for (const prop in window) { if (window.hasOwnProperty(prop)) { if (window[prop] !== this.originalSnapshot[prop]) { this.modifyPropsMap[prop] = window[prop]; window[prop] = this.originalSnapshot[prop]; } } } } } function excuteAppA() { window.a = 'a'; window.aa = 'aa'; } function excuteAppB() { window.b = 'b'; window.bb = 'bb'; } function showConsole() { console.log(window.begin, window.a, window.aa, window.b, window.bb); } // begin 在挂载应用之前,可能会有其他的库在window上挂载一些内容 window.begin = 'some value'; // 创建A B应用的沙箱 const sandboxA = new SnapshotSandbox(); const sandboxB = new SnapshotSandbox(); // 看看当前window的结果 showConsole(); // 假设初始化时挂载A应用 sandboxA.active(); // 挂载完毕后,A应用可能会执行它自己的逻辑 excuteAppA(); // 看看当前window的结果 showConsole(); // 从应用A切换至B 经历A失活 B激活 sandboxA.inActive(); sandboxB.active(); // 看看当前window的结果 showConsole(); // 挂载完毕后,B应用也可能会执行它自己的逻辑 excuteAppB(); // 看看当前window的结果 showConsole(); // 从应用B切换至A 经历B失活 A激活 sandboxB.inActive(); sandboxA.active(); // 看看当前window的结果 showConsole(); /* some value undefined undefined undefined undefined some value a aa undefined undefined some value undefined undefined undefined undefined some value undefined undefined b bb some value a aa undefined undefined */
飞冰@ice/sandbox源码
export interface SandboxProps { multiMode?: boolean; } export interface SandboxConstructor { new(): Sandbox; } // check window constructor function, like Object Array function isConstructor(fn) { // generator function and has own prototype properties const hasConstructor = fn.prototype && fn.prototype.constructor === fn && Object.getOwnPropertyNames(fn.prototype).length > 1; // unnecessary to call toString if it has constructor function const functionStr = !hasConstructor && fn.toString(); const upperCaseRegex = /^function\s+[A-Z]/; return ( hasConstructor || // upper case upperCaseRegex.test(functionStr) || // ES6 class, window function do not have this case functionStr.slice(0, 5) === 'class' ); } // get function from original window, such as scrollTo, parseInt function isWindowFunction(func) { return func && typeof func === 'function' && !isConstructor(func); } export default class Sandbox { private sandbox: Window; private multiMode = false; private eventListeners = {}; private timeoutIds: number[] = []; private intervalIds: number[] = []; //存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量 private propertyAdded = {}; //存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量 private originalValues = {}; public sandboxDisabled: boolean; constructor(props: SandboxProps = {}) { const { multiMode } = props; if (!window.Proxy) { console.warn('proxy sandbox is not support by current browser'); this.sandboxDisabled = true; } // enable multiMode in case of create mulit sandbox in same time this.multiMode = multiMode; this.sandbox = null; } createProxySandbox(injection?: object) { const { propertyAdded, originalValues, multiMode } = this; const proxyWindow = Object.create(null) as Window; const originalWindow = window; const originalAddEventListener = window.addEventListener; const originalRemoveEventListener = window.removeEventListener; const originalSetInterval = window.setInterval; const originalSetTimeout = window.setTimeout; // hijack addEventListener proxyWindow.addEventListener = (eventName, fn, ...rest) => { this.eventListeners[eventName] = (this.eventListeners[eventName] || []); this.eventListeners[eventName].push(fn); return originalAddEventListener.apply(originalWindow, [eventName, fn, ...rest]); }; // hijack removeEventListener proxyWindow.removeEventListener = (eventName, fn, ...rest) => { const listeners = this.eventListeners[eventName] || []; if (listeners.includes(fn)) { listeners.splice(listeners.indexOf(fn), 1); } return originalRemoveEventListener.apply(originalWindow, [eventName, fn, ...rest]); }; // hijack setTimeout proxyWindow.setTimeout = (...args) => { const timerId = originalSetTimeout(...args); this.timeoutIds.push(timerId); return timerId; }; // hijack setInterval proxyWindow.setInterval = (...args) => { const intervalId = originalSetInterval(...args); this.intervalIds.push(intervalId); return intervalId; }; // 创建对proxyWindow的代理,proxyWindow就是我们传递给自执行函数的window对象 const sandbox = new Proxy(proxyWindow, { set(target: Window, p: PropertyKey, value: any): boolean { if (!originalWindow.hasOwnProperty(p)) { // 如果window对象上没有这个属性,那么就在状态池中记录状态的新增; propertyAdded[p] = value; } else if (!originalValues.hasOwnProperty(p)) { //如果window对象上有这个p属性,并且originalValues没有这个p属性, // 那么证明改属性是运行时期间更新的值,记录在状态池中用于最后window对象的还原 originalValues[p] = originalWindow[p]; } // set new value to original window in case of jsonp, js bundle which will be execute outof sandbox if (!multiMode) { originalWindow[p] = value; } // eslint-disable-next-line no-param-reassign target[p] = value; return true; }, get(target: Window, p: PropertyKey): any { if (p === Symbol.unscopables) { return undefined; } if (['top', 'window', 'self', 'globalThis'].includes(p as string)) { return sandbox; } // proxy hasOwnProperty, in case of proxy.hasOwnProperty value represented as originalWindow.hasOwnProperty if (p === 'hasOwnProperty') { // eslint-disable-next-line no-prototype-builtins return (key: PropertyKey) => !!target[key] || originalWindow.hasOwnProperty(key); } const targetValue = target[p]; /** * Falsy value like 0/ ''/ false should be trapped by proxy window. */ if (targetValue !== undefined) { // case of addEventListener, removeEventListener, setTimeout, setInterval setted in sandbox return targetValue; } // search from injection const injectionValue = injection && injection[p]; if (injectionValue) { return injectionValue; } const value = originalWindow[p]; /** * use `eval` indirectly if you bind it. And if eval code is not being evaluated by a direct call, * then initialise the execution context as if it was a global execution context. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval * https://262.ecma-international.org/5.1/#sec-10.4.2 */ if (p === 'eval') { return value; } if (isWindowFunction(value)) { // When run into some window's functions, such as `console.table`, // an illegal invocation exception is thrown. const boundValue = value.bind(originalWindow); // Axios, Moment, and other callable functions may have additional properties. // Simply copy them into boundValue. for (const key in value) { boundValue[key] = value[key]; } return boundValue; } else { // case of window.clientWidth、new window.Object() return value; } }, has(target: Window, p: PropertyKey): boolean { return p in target || p in originalWindow; }, }); this.sandbox = sandbox; } getSandbox() { return this.sandbox; } getAddedProperties() { return this.propertyAdded; } execScriptInSandbox(script: string): void { if (!this.sandboxDisabled) { // create sandbox before exec script if (!this.sandbox) { this.createProxySandbox(); } try { const execScript = `with (sandbox) {;${script}\n}`; // eslint-disable-next-line no-new-func const code = new Function('sandbox', execScript).bind(this.sandbox); // run code with sandbox code(this.sandbox); } catch (error) { console.error(`error occurs when execute script in sandbox: ${error}`); throw error; } } } clear() { //子应用卸载还原 if (!this.sandboxDisabled) { // remove event listeners Object.keys(this.eventListeners).forEach((eventName) => { (this.eventListeners[eventName] || []).forEach((listener) => { window.removeEventListener(eventName, listener); }); }); // clear timeout this.timeoutIds.forEach((id) => window.clearTimeout(id)); this.intervalIds.forEach((id) => window.clearInterval(id)); // recover original values Object.keys(this.originalValues).forEach((key) => { window[key] = this.originalValues[key]; }); Object.keys(this.propertyAdded).forEach((key) => { delete window[key]; }); } } } const sandbox = new Sandbox(); const script = ` window.hh = 1 console.log(hh) ` sandbox.execScriptInSandbox(script); sandbox.clear();
1、本质上还是操作 window 对象,但是他设置了两个状态池,用于子应用卸载时还原主应用的状态propertyAdded: 存储在子应用运行时期间新增的全局变量,用于卸载子应用时还原主应用全局变量
originalValues:存储在子应用运行期间更新的全局变量,用于卸载子应用时还原主应用全局变量
2、通过
- window.gg = 1; 在主应用中不存在,这个gg会被记录到propertyAdded中
- window.pageXOffset = 100;全局window中存在此变量,本次更新后会记录到originalValues中
沙箱逃逸
针对可控的二方应用,正常书写代码是不会有问题的。在上述提到的隔离方案下,如果微应用想要恶意污染的话基本是无法杜绝的,因此针对这种不可控的微应用建议还是通过 iframe 的方式接入。
- 访问沙箱执行上下文中某个对象内部属性时,const sandbox = new Sandbox();
const code1 = `
window.parent.abc = 'xxx'
`
const code2 = `
({}).constructor.prototype.toString = () => {
console.log('Escape!')
}
`
sandbox.execScriptInSandbox(code1);
window.abc //xxx
sandbox.execScriptInSandbox(code2);
({}).toString() // Escape! 预期是 [object Object]
参考文献
Proxy API https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy
Proxy浅析 http://t.zoukankan.com/goloving-p-12898003.html从QIANKUN看沙箱隔离 https://www.freesion.com/article/49731468342/
https://blog.csdn.net/wuyxinu/article/details/117705022#SingleSpaqiankun_1
https://zhuanlan.zhihu.com/p/450103808
new Function + with https://blog.csdn.net/qq_34629352/article/details/119848892
作者:淑娟
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· .NET10 - 预览版1新功能体验(一)