7

Micro front-end has become a hot topic in the front-end field. In terms of technology, there is a topic that cannot be bypassed in micro front-end is the front-end sandbox.

What is a sandbox

Sandboxie (also called sandbox, sandbox) is a virtual system program that allows you to run a browser or other programs in the sandbox environment, so the changes produced by the operation can be deleted later. It creates an independent operating environment similar to a sandbox, and the programs running in it cannot have a permanent impact on the hard disk. In network security, sandbox refers to a tool used to test behaviors such as untrusted files or applications in an isolated environment

Simply put, a sandbox is an environment isolated from the outside world. The internal and external environments do not affect each other. The outside world cannot modify any information in the environment. The things in the sandbox belong to a single world.

JavaScript sandbox

For JavaScript, the sandbox is not a sandbox in the traditional sense. It is just a grammatical hack. The sandbox is a security mechanism that runs some untrusted code in the sandbox and makes it inaccessible. Code outside the sandbox. When you need to parse or execute untrusted JavaScript, you need to isolate the execution environment of the executed code, and you need to restrict the accessible objects in the executed code. Usually, you can start to call the closure that handles module dependencies in JavaScript. As a sandbox.

JavaScript sandbox implementation

We can roughly divide the overall implementation of the sandbox into two parts:

  • Build a closure environment
  • Simulate native browser objects

Build a closure environment

We know that in JavaScript, with regard to scope, there are only global scope, function scope, and block scope, which is only available starting from ES6. If you want to isolate the definitions of variables, functions, etc. in a piece of code, limited by JavaScript's control of the scope, you can only encapsulate this piece of code in a Function, and achieve the purpose of scope isolation by using function scope. Also because of the need for this way of using functions to achieve the purpose of scope isolation, there is IIFE (immediately call function expression), which is a design pattern called self-executing anonymous functions

 (function foo(){
    var a = 1;
    console.log(a);
 })();
 // 无法从外部访问变量 name
console.log(a) // 抛出错误:"Uncaught ReferenceError: a is not defined"

When a function becomes a function expression that is executed immediately, the variables in the expression cannot be accessed from the outside, and it has an independent lexical scope. It not only avoids external access to the variables in IIFE, but also does not pollute the global scope, which makes up for JavaScript's scope defects. Generally common when writing plug-ins and class libraries, such as the sandbox mode in JQuery

(function (window) {
    var jQuery = function (selector, context) {
        return new jQuery.fn.init(selector, context);
    }
    jQuery.fn = jQuery.prototype = function () {
        //原型上的方法,即所有jQuery对象都可以共享的方法和属性
    }
    jQuery.fn.init.prototype = jQuery.fn;
    window.jQeury = window.$ = jQuery; //如果需要在外界暴露一些属性或者方法,可以将这些属性和方法加到window全局对象上去
})(window);

When IIFE is assigned to a variable, it is not storing the IIFE itself, but storing the result returned after the IIFE is executed.

var result = (function () {
    var name = "张三";
    return name;
})();
console.log(result); // "张三"

Simulation of native browser objects

The purpose of simulating native browser objects is to prevent the closure of the environment and manipulate native objects. Tampering to pollute the native environment; we need to pay attention to several infrequently used APIs before completing the simulation of browser objects.

eval

The eval function can convert a string to code execution and return one or more values

   var b = eval("({name:'张三'})")
   console.log(b.name);

Since the code executed by eval can access the closure and the global scope, this leads to the security problem of code injection, because the code can be found inside the scope chain and tampered with global variables, which is undesirable.

new Function

The Function constructor creates a new Function object. Call this constructor directly to create a function dynamically

grammar

new Function ([arg1[, arg2[, ...argN]],] functionBody)

arg1, arg2, ... argN The names of the parameters used by the function must be legally named. The parameter name is a string of valid JavaScript identifiers, or a comma-separated list of valid strings; for example, "×", "theValue", or "a,b".

functionBody
A string containing JavaScript statements including function definitions.

const sum = new Function('a', 'b', 'return a + b');

console.log(sum(1, 2));//3

It will also encounter similar security issues and relatively minor performance issues with eval.

var a = 1;

function sandbox() {
    var a = 2;
    return new Function('return a;'); // 这里的 a 指向最上面全局作用域内的 1
}
var f = sandbox();
console.log(f())

The difference with eval is that functions created by Function can only be run in the global scope. It cannot access local closure variables, they are always created in the global environment, so they can only access global variables and their own local variables at runtime, and cannot access variables in the scope where they were created by the Function constructor; but , It can still access the global scope. new Function() is a better alternative to eval(). It has excellent performance and security, but it still does not solve the problem of accessing the whole world.

with

with is a keyword in JavaScript that extends the scope chain of a statement. It allows semi-sandbox execution. So what is a semi-sandbox? The statement adds an object to the top of the scope chain. If there is an unused namespace variable in the sandbox with the same name as an attribute in the scope chain, this variable will point to the attribute value. If there is no attribute with the same name, a ReferenceError will be thrown.

      function sandbox(o) {
            with (o){
                //a=5; 
                c=2;
                d=3;
                console.log(a,b,c,d); // 0,1,2,3 //每个变量首先被认为是一个局部变量,如果局部变量与 obj 对象的某个属性同名,则这个局部变量会指向 obj 对象属性。
            }
            
        }
        var f = {
            a:0,
            b:1
        }
        sandbox(f);       
        console.log(f);
        console.log(c,d); // 2,3 c、d被泄露到window对象上

Investigating its principle, with uses the in operator internally. For each variable access in the block, it calculates the variable under sandbox conditions. If the condition is true, it will retrieve the variables from the sandbox. Otherwise, the variable is looked up in the global scope. But the with statement makes the program search the specified object first when looking up the value of the variable. So for those variables that are not attributes of this object, it will be very slow to find, and it is not suitable for programs with performance requirements (JavaScript engine will perform several performance optimizations during the compilation stage. Some of these optimizations depend on the lexical of the code Perform static analysis and determine the location of all variables and functions in advance, so that the identifier can be quickly found during the execution process.). with will also cause data leakage (in non-strict mode, a global variable will be automatically created in the global scope)

in operator

The in operator can detect whether the left operand is a member of the right operand. Among them, the left operand is a string or an expression that can be converted to a string, and the right operand is an object or array.
    var o = {  
        a : 1,  
        b : function() {}
    }
    console.log("a" in o);  //true
    console.log("b" in o);  //true
    console.log("c" in o);  //false
    console.log("valueOf" in o);  //返回true,继承Object的原型方法
    console.log("constructor" in o);  //返回true,继承Object的原型属性

with + new Function

With the usage of with, you can slightly limit the scope of the sandbox. First provide object search from the current with, but if you can't find it, you can still get it from above, polluting or tampering with the global environment.

function sandbox (src) {
    src = 'with (sandbox) {' + src + '}'
    return new Function('sandbox', src)
}
var str = 'let a = 1;window.name="张三";console.log(a);console.log(b)'
var b = 2
sandbox(str)({});
console.log(window.name);//'张三'

Proxy-based sandbox (ProxySandbox)

Thinking from the above part of the content, if you can use with to restrict access to each variable in the block to calculate the variable under the sandbox condition, and retrieve the variable from the sandbox. So is it possible to perfectly solve the JavaScript sandbox mechanism?

Use with plus proxy to implement JavaScript sandbox

ES6 Proxy is used to modify the default behavior of certain operations, which is equivalent to making changes at the language level, which is a kind of "meta programming" (meta programming)
function sandbox(code) {
    code = 'with (sandbox) {' + code + '}'
    const fn = new Function('sandbox', code)

    return function (sandbox) {
        const sandboxProxy = new Proxy(sandbox, {
            has(target, key) {
                return true
            }
        })
        return fn(sandboxProxy)
    }
}
var a = 1;
var code = 'console.log(a)' // TypeError: Cannot read property 'log' of undefined
sandbox(code)({})

We mentioned earlier that with uses the in operator internally to calculate variables. If the condition is true, it will retrieve the variables from the sandbox. Ideally, there is no problem, but there are always special exceptions, such as Symbol.unscopables.

Symbol.unscopables

The Symbol.unscopables property of the Symbol.unscopables object points to an object. This object specifies which attributes will be excluded by the with environment when the with keyword is used.
Array.prototype[Symbol.unscopables]
// {
//   copyWithin: true,
//   entries: true,
//   fill: true,
//   find: true,
//   findIndex: true,
//   keys: true
// }

Object.keys(Array.prototype[Symbol.unscopables])
// ['copyWithin', 'entries', 'fill', 'find', 'findIndex', 'keys']

The above code shows that the array has 6 attributes, which will be excluded by the with command.

file

Therefore, our code also needs to be modified as follows:

function sandbox(code) {
    code = 'with (sandbox) {' + code + '}'
    const fn = new Function('sandbox', code)

    return function (sandbox) {
        const sandboxProxy = new Proxy(sandbox, {
            has(target, key) {
                return true
            },
            get(target, key) {
                if (key === Symbol.unscopables) return undefined
                return target[key]
            }
        })
        return fn(sandboxProxy)
    }
}
var test = {
    a: 1,
    log(){
        console.log('11111')
    }
}
var code = 'log();console.log(a)' // 1111,TypeError: Cannot read property 'log' of undefined
sandbox(code)(test)

Symbol.unscopables defines the inoperable properties of the object. The Unscopeable attribute is never retrieved from the sandbox object in the with statement, but directly from the closure or global scope.

Snapshot Sandbox

The following is the source code of qiankun's snapshotSandbox, here are some simplifications and comments to help understand.

        function iter(obj, callbackFn) {
            for (const prop in obj) {
                if (obj.hasOwnProperty(prop)) {
                    callbackFn(prop);
                }
            }
        }

        /**
         * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
         */
        class SnapshotSandbox {
            constructor(name) {
                this.name = name;
                this.proxy = window;
                this.type = 'Snapshot';
                this.sandboxRunning = true;
                this.windowSnapshot = {};
                this.modifyPropsMap = {};
                this.active();
            }
            //激活
            active() {
                // 记录当前快照
                this.windowSnapshot = {};
                iter(window, (prop) => {
                    this.windowSnapshot[prop] = window[prop];
                });

                // 恢复之前的变更
                Object.keys(this.modifyPropsMap).forEach((p) => {
                    window[p] = this.modifyPropsMap[p];
                });

                this.sandboxRunning = true;
            }
            //还原
            inactive() {
                this.modifyPropsMap = {};

                iter(window, (prop) => {
                    if (window[prop] !== this.windowSnapshot[prop]) {
                        // 记录变更,恢复环境
                        this.modifyPropsMap[prop] = window[prop];
                      
                        window[prop] = this.windowSnapshot[prop];
                    }
                });
                this.sandboxRunning = false;
            }
        }
        let sandbox = new SnapshotSandbox();
        //test
        ((window) => {
            window.name = '张三'
            window.age = 18
            console.log(window.name, window.age) //    张三,18
            sandbox.inactive() //    还原
            console.log(window.name, window.age) //    undefined,undefined
            sandbox.active() //    激活
            console.log(window.name, window.age) //    张三,18
        })(sandbox.proxy);

Snapshot sandbox implementation is relatively simple. It is mainly used for low-level browsers that do not support Proxy. The principle is based on diff . When the sub-application is activated or uninstalled, the sandbox is realized by recording or restoring the state in the form of a snapshot. , SnapshotSandbox will pollute the global window.

legacySandBox

The proxy sandbox is implemented in the singular mode of the qiankun framework. In order to facilitate understanding, part of the code is simplified and commented here.

//legacySandBox
const callableFnCacheMap = new WeakMap();

function isCallable(fn) {
  if (callableFnCacheMap.has(fn)) {
    return true;
  }
  const naughtySafari = typeof document.all === 'function' && typeof document.all === 'undefined';
  const callable = naughtySafari ? typeof fn === 'function' && typeof fn !== 'undefined' : typeof fn ===
    'function';
  if (callable) {
    callableFnCacheMap.set(fn, callable);
  }
  return callable;
};

function isPropConfigurable(target, prop) {
  const descriptor = Object.getOwnPropertyDescriptor(target, prop);
  return descriptor ? descriptor.configurable : true;
}

function setWindowProp(prop, value, toDelete) {
  if (value === undefined && toDelete) {
    delete window[prop];
  } else if (isPropConfigurable(window, prop) && typeof prop !== 'symbol') {
    Object.defineProperty(window, prop, {
      writable: true,
      configurable: true
    });
    window[prop] = value;
  }
}


function getTargetValue(target, value) {
  /*
    仅绑定 isCallable && !isBoundedFunction && !isConstructable 的函数对象,如 window.console、window.atob 这类。目前没有完美的检测方式,这里通过 prototype 中是否还有可枚举的拓展方法的方式来判断
    @warning 这里不要随意替换成别的判断方式,因为可能触发一些 edge case(比如在 lodash.isFunction 在 iframe 上下文中可能由于调用了 top window 对象触发的安全异常)
   */
  if (isCallable(value) && !isBoundedFunction(value) && !isConstructable(value)) {
    const boundValue = Function.prototype.bind.call(value, target);
    for (const key in value) {
      boundValue[key] = value[key];
    }
    if (value.hasOwnProperty('prototype') && !boundValue.hasOwnProperty('prototype')) {
      Object.defineProperty(boundValue, 'prototype', {
        value: value.prototype,
        enumerable: false,
        writable: true
      });
    }

    return boundValue;
  }

  return value;
}

/**
 * 基于 Proxy 实现的沙箱
 */
class SingularProxySandbox {
  /** 沙箱期间新增的全局变量 */
  addedPropsMapInSandbox = new Map();

  /** 沙箱期间更新的全局变量 */
  modifiedPropsOriginalValueMapInSandbox = new Map();

  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  currentUpdatedPropsValueMap = new Map();

  name;

  proxy;

  type = 'LegacyProxy';

  sandboxRunning = true;

  latestSetProp = null;

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    // console.log(' this.modifiedPropsOriginalValueMapInSandbox', this.modifiedPropsOriginalValueMapInSandbox)
    // console.log(' this.addedPropsMapInSandbox', this.addedPropsMapInSandbox)
    //删除添加的属性,修改已有的属性
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

  constructor(name) {
    this.name = name;
    const {
      addedPropsMapInSandbox,
      modifiedPropsOriginalValueMapInSandbox,
      currentUpdatedPropsValueMap
    } = this;

    const rawWindow = window;
    //Object.create(null)的方式,传入一个不含有原型链的对象
    const fakeWindow = Object.create(null); 

    const proxy = new Proxy(fakeWindow, {
      set: (_, p, value) => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
            const originalValue = rawWindow[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }

          currentUpdatedPropsValueMap.set(p, value);
          // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
          rawWindow[p] = value;

          this.latestSetProp = p;

          return true;
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get(_, p) {
        //避免使用 window.window 或者 window.self 逃离沙箱环境,触发到真实环境
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = rawWindow[p];
        return getTargetValue(rawWindow, value);
      },

      has(_, p) { //返回boolean
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_, p) {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // 如果属性不作为目标对象的自身属性存在,则不能将其设置为不可配置
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },
    });

    this.proxy = proxy;
  }
}

let sandbox = new SingularProxySandbox();

((window) => {
  window.name = '张三';
  window.age = 18;
  window.sex = '男';
  console.log(window.name, window.age,window.sex) //    张三,18,男
  sandbox.inactive() //    还原
  console.log(window.name, window.age,window.sex) //    张三,undefined,undefined
  sandbox.active() //    激活
  console.log(window.name, window.age,window.sex) //    张三,18,男
})(sandbox.proxy); //test

legacySandBox still manipulates the window object, but it achieves sandbox isolation by restoring the state of the atomic application when the sandbox is activated, and restoring the state of the main application when uninstalling, which will also pollute the window, but the performance is better than the snapshot sandbox. Traverse the window object.

proxySandbox (multiple sandboxes)

In qiankun's sandbox proxySandbox source code, the fakeWindow object is proxied, and this object is obtained through the createFakeWindow method. This method is to copy the document, location, top, window, etc. attributes of the window and give it to fakeWindow .

Source code display:


function createFakeWindow(global: Window) {
  // map always has the fastest performance in has check scenario
  // see https://jsperf.com/array-indexof-vs-set-has/23
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  /*
   copy the non-configurable property of global to fakeWindow
   see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
   > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
   */
  Object.getOwnPropertyNames(global)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        /*
         make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
         > The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
         */
        if (
          p === 'top' ||
          p === 'parent' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          descriptor.configurable = true;
          /*
           The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
           Example:
            Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
            Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
           */
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

Since proxySandbox is a copy of fakeWindow, it will not pollute the global window and supports simultaneous loading of multiple sub-applications.
For detailed source code, please check : proxySandbox

About CSS isolation

Common ones are:

  • CSS Module
  • namespace
  • Dynamic StyleSheet
  • css in js
  • Shadow DOM
    We will not repeat the common ones here, but here we will focus on Shadow DO.

Shadow DOM

Shadow DOM allows the hidden DOM tree to be attached to the regular DOM tree-it starts with the shadow root node as the starting root node. Below this root node, it can be any element, just like ordinary DOM elements.

file

This article is published by the blog one article multi-posting OpenWrite

袋鼠云数栈UED
286 声望38 粉丝

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。