JavaScript 沙盒模式


微前端已經成為前端領域比較火爆的話題,在技術方面,微前端有一個始終繞不過去的話題就是前端沙箱

什么是沙箱

Sandboxie(又叫沙箱、沙盤)即是一個虛擬系統程序,允許你在沙盤環境中運行瀏覽器或其他程序,因此運行所產生的變化可以隨后刪除。它創造了一個類似沙盒的獨立作業環境,在其內部運行的程序並不能對硬盤產生永久性的影響。 在網絡安全中,沙箱指在隔離環境中,用以測試不受信任的文件或應用程序等行為的工具

簡單來說沙箱(sandbox)就是與外界隔絕的一個環境,內外環境互不影響,外界無法修改該環境內任何信息,沙箱內的東西單獨屬於一個世界。

JavaScript 的沙箱

對於 JavaScript 來說,沙箱並非傳統意義上的沙箱,它只是一種語法上的 Hack 寫法,沙箱是一種安全機制,把一些不信任的代碼運行在沙箱之內,使其不能訪問沙箱之外的代碼。當需要解析或着執行不可信的 JavaScript 的時候,需要隔離被執行代碼的執行環境的時候,需要對執行代碼中可訪問對象進行限制,通常開始可以把 JavaScript 中處理模塊依賴關系的閉包稱之為沙箱。

JavaScript 沙箱實現

我們大致可以把沙箱的實現總體分為兩個部分:

  • 構建一個閉包環境
  • 模擬原生瀏覽器對象

構建閉包環境

我們知道 JavaScript 中,關於作用域(scope),只有全局作用域(global scope)、函數作用域(function scope)以及從 ES6 開始才有的塊級作用域(block scope)。如果要將一段代碼中的變量、函數等的定義隔離出來,受限於 JavaScript 對作用域的控制,只能將這段代碼封裝到一個 Function 中,通過使用 function scope 來達到作用域隔離的目的。也因為需要這種使用函數來達到作用域隔離的目的方式,於是就有 IIFE(立即調用函數表達式),這是一個被稱為 自執行匿名函數的設計模式

 (function foo(){
    var a = 1;
    console.log(a);
 })();
 // 無法從外部訪問變量 name
console.log(a) // 拋出錯誤:"Uncaught ReferenceError: a is not defined"

當函數變成立即執行的函數表達式時,表達式中的變量不能從外部訪問,它擁有獨立的詞法作用域。不僅避免了外界訪問 IIFE 中的變量,而且又不會污染全局作用域,彌補了 JavaScript 在 scope 方面的缺陷。一般常見於寫插件和類庫時,如 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);

當將 IIFE 分配給一個變量,不是存儲 IIFE 本身,而是存儲 IIFE 執行后返回的結果。

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

原生瀏覽器對象的模擬

模擬原生瀏覽器對象的目的是為了,防止閉包環境,操作原生對象。篡改污染原生環境;完成模擬瀏覽器對象之前我們需要先關注幾個不常用的 API。

eval

eval 函數可將字符串轉換為代碼執行,並返回一個或多個值

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

由於 eval 執行的代碼可以訪問閉包和全局范圍,因此就導致了代碼注入的安全問題,因為代碼內部可以沿着作用域鏈往上找,篡改全局變量,這是我們不希望的

new Function

Function 構造函數創建一個新的 Function 對象。直接調用這個構造函數可用動態創建函數

語法

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

arg1, arg2, ... argN 被函數使用的參數的名稱必須是合法命名的。參數名稱是一個有效的 JavaScript 標識符的字符串,或者一個用逗號分隔的有效字符串的列表;例如“×”,“theValue”,或“a,b”。

functionBody
一個含有包括函數定義的 JavaScript 語句的字符串。

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

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

同樣也會遇到和 eval 類似的的安全問題和相對較小的性能問題。

var a = 1;

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

與 eval 不同的是 Function 創建的函數只能在全局作用域中運行。它無法訪問局部閉包變量,它們總是被創建於全局環境,因此在運行時它們只能訪問全局變量和自己的局部變量,不能訪問它們被 Function 構造器創建時所在的作用域的變量;但是,它仍然可以訪問全局范圍。new Function()是 eval()更好替代方案。它具有卓越的性能和安全性,但仍沒有解決訪問全局的問題。

with

with 是 JavaScript 中一個關鍵字,擴展一個語句的作用域鏈。它允許半沙盒執行。那什么叫半沙盒?語句將某個對象添加到作用域鏈的頂部,如果在沙盒中有某個未使用命名空間的變量,跟作用域鏈中的某個屬性同名,則這個變量將指向這個屬性值。如果沒有同名的屬性,則將拋出 ReferenceError。

      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對象上

究其原理,with在內部使用in運算符。對於塊內的每個變量訪問,它都在沙盒條件下計算變量。如果條件是 true,它將從沙盒中檢索變量。否則,就在全局范圍內查找變量。但是 with 語句使程序在查找變量值時,都是先在指定的對象中查找。所以對於那些本來不是這個對象的屬性的變量,查找起來會很慢,對於有性能要求的程序不適合(JavaScript 引擎會在編譯階段進行數項的性能優化。其中有些優化依賴於能夠根據代碼的詞法進行靜態分析,並預先確定所有變量和函數的定義位置,才能在執行過程中快速找到標識符。)。with 也會導致數據泄漏(在非嚴格模式下,會自動在全局作用域創建一個全局變量)

in 運算符

in 運算符能夠檢測左側操作數是否為右側操作數的成員。其中,左側操作數是一個字符串,或者可以轉換為字符串的表達式,右側操作數是一個對象或數組。

    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 用法可以稍微限制沙盒作用域,先從當前的 with 提供對象查找,但是如果查找不到依然還能從上獲取,污染或篡改全局環境。

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 實現的沙箱(ProxySandbox)

由上部分內容思考,假如可以做到在使用with對於塊內的每個變量訪問都限制在沙盒條件下計算變量,從沙盒中檢索變量。那么是否可以完美的解決JavaScript沙箱機制。

使用 with 再加上 proxy 實現 JavaScript 沙箱

ES6 Proxy 用於修改某些操作的默認行為,等同於在語言層面做出修改,屬於一種“元編程”(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)({})

我們前面提到with在內部使用in運算符來計算變量,如果條件是 true,它將從沙盒中檢索變量。理想狀態下沒有問題,但也總有些特例獨行的存在,比如 Symbol.unscopables。

Symbol.unscopables

Symbol.unscopables 對象的 Symbol.unscopables 屬性,指向一個對象。該對象指定了使用 with 關鍵字時,哪些屬性會被 with 環境排除。

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']

上面代碼說明,數組有 6 個屬性,會被 with 命令排除。

file

由此我們的代碼還需要修改如下:

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 定義對象的不可作用屬性。Unscopeable 屬性永遠不會從 with 語句中的沙箱對象中檢索,而是直接從閉包或全局范圍中檢索。

快照沙箱(SnapshotSandbox)

以下是 qiankun 的 snapshotSandbox 的源碼,這里為了幫助理解做部分精簡及注釋。

        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);

快照沙箱實現來說比較簡單,主要用於不支持 Proxy 的低版本瀏覽器,原理是基於diff來實現的,在子應用激活或者卸載時分別去通過快照的形式記錄或還原狀態來實現沙箱,snapshotSandbox 會污染全局 window。

legacySandBox

qiankun 框架 singular 模式下 proxy 沙箱實現,為了便於理解,這里做了部分代碼的精簡和注釋。

//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 還是會操作 window 對象,但是他通過激活沙箱時還原子應用的狀態,卸載時還原主應用的狀態來實現沙箱隔離的,同樣會對 window 造成污染,但是性能比快照沙箱好,不用遍歷 window 對象。

proxySandbox(多例沙箱)

在 qiankun 的沙箱 proxySandbox 源碼里面是對 fakeWindow 這個對象進行了代理,而這個對象是通過 createFakeWindow 方法得到的,這個方法是將 window 的 document、location、top、window 等等屬性拷貝一份,給到 fakeWindow。

源碼展示:


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,
  };
}

proxySandbox 由於是拷貝復制了一份 fakeWindow,不會污染全局 window,同時支持多個子應用同時加載。
詳細源碼請查看:proxySandbox

關於 CSS 隔離

常見的有:

  • CSS Module
  • namespace
  • Dynamic StyleSheet
  • css in js
  • Shadow DOM
    常見的我們這邊不再贅述,這里我們重點提一下Shadow DO。

Shadow DOM

Shadow DOM 允許將隱藏的 DOM 樹附加到常規的 DOM 樹中——它以 shadow root 節點為起始根節點,在這個根節點的下方,可以是任意元素,和普通的 DOM 元素一樣。

file

本文由博客一文多發平台 OpenWrite 發布!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM