Live2D

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、通过

3、当子应用卸载时调用clear,将状态池中记录的字段,还原window。
4、与快照沙箱类似,也是只适用单实例场景。因为使用了Proxy代理,无须像快照沙箱挂载和卸载时遍历window属性了,性能高很多。

沙箱逃逸

针对可控的二方应用,正常书写代码是不会有问题的。在上述提到的隔离方案下,如果微应用想要恶意污染的话基本是无法杜绝的,因此针对这种不可控的微应用建议还是通过 iframe 的方式接入。

posted @ 2022-06-17 17:14  喻佳文  阅读(607)  评论(0编辑  收藏  举报