一文徹底搞懂前端沙箱

原文地址:https://www.yuque.com/wangxiangzhong/mvugau/bgs3po

作者:噶牛

什麼是 “沙箱”

沙箱(Sandbox)[1]

也稱作:“沙箱 / 沙盒 / 沙盤”。沙箱是一種安全機制,爲運行中的程序提供隔離環境。通常是作爲一些來源不可信、具破壞力或無法判定程序意圖的程序提供實驗之用。沙箱能夠安全的執行不受信任的代碼,且不影響外部實際代碼影響的獨立環境。

有哪些動態執行腳本的場景?

在一些應用中,我們希望給用戶提供插入自定義邏輯的能力,比如 Microsoft 的 Office 中的 VBA,比如一些遊戲中的 lua 腳本,FireFox 的「油猴腳本」,能夠讓用戶發在可控的範圍和權限內發揮想象做一些好玩、有用的事情,擴展了能力,滿足用戶的個性化需求。

大多數都是一些客戶端程序,在一些在線的系統和產品中也常常也有類似的需求,事實上,在線的應用中也有不少提供了自定義腳本的能力,比如 Google Docs 中的 Apps Script,它可以讓你使用 JavaScript 做一些非常有用的事情,比如運行代碼來響應文檔打開事件或單元格更改事件,爲公式製作自定義電子表格函數等等。

與運行在「用戶電腦中」的客戶端應用不同,用戶的自定義腳本通常只能影響用戶自已,而對於在線的應用或服務來講,有一些情況就變得更爲重要,比如「安全」,用戶的「自定義腳本」必須嚴格受到限制和隔離,即不能影響到宿主程序,也不能影響到其它用戶。

另外,有一些牽扯「模板化」的前端框架,如 Vue.js、Venom.js 等都會用到動態代碼執行。

JavaScript 中的沙箱實現

零、幾個基礎知識

什麼是 constructor

•JavaScript 中 constructor 屬性指向創建當前對象的構造函數,該屬性是存在原型裏的,且是不可靠的 JavaScript 中 constructor 屬性 [2]

function test() {}
const obj = new test();
console.log(obj.hasOwnProperty('constructor')); // false
console.log(obj.__proto__.hasOwnProperty('constructor')); // true
console.log(obj.__proto__ === test.prototype); // true
console.log(test.prototype.hasOwnProperty('constructor')); // true

/** constructor是不可靠的 */
function Foo() {}
Foo.prototype = {};
const foo = new Foo();
console.log(foo.constructor === Object);  // true,可以看出不是Foo了

•constructor 也是一種用於創建和初始化 class[3] 創建的對象的特殊方法 Class 構造方法 [4]

幾個典型的 constructor:

(async function(){})().constructor === Promise

// 瀏覽器環境下
this.constructor.constructor === Function
window.constructor.constructor === Function

// node環境下
this.constructor.constructor === Function
global.constructor.constructor === Function

JS Proxy getPrototypeOf()

handler.getPrototypeOf()是一個代理方法,當讀取代理對象的原型時,該方法就會被調用。語法:

const p = new Proxy(obj, {
  getPrototypeOf(target) { // target 被代理的目標對象。
  ...
  }
});

當 getPrototypeOf 方法被調用時,this 指向的是它所屬的處理器對象,getPrototypeOf 方法的返回值必須是一個對象或者 null。

在 JavaScript 中,有下面這五種操作(方法 / 屬性 / 運算符)可以觸發 JS 引擎讀取一個對象的原型,也就是可以觸發 getPrototypeOf() 代理方法的運行:

•Object.getPrototypeOf()[5]•Reflect.getPrototypeOf()[6]•proto[7]•Object.prototype.isPrototypeOf()[8]•instanceof[9]

如果遇到了下面兩種情況,JS 引擎會拋出 TypeError[10] 異常:

•getPrototypeOf() 方法返回的不是對象也不是 null。• 目標對象是不可擴展的,且 getPrototypeOf() 方法返回的原型不是目標對象本身的原型

基本用法:

const obj = {};
const proto = {};
const handler = {
    getPrototypeOf(target) {
        console.log(target === obj);   // true
        console.log(this === handler); // true
        return proto;
    }
};

var p = new Proxy(obj, handler); // obj是被代理的對象,也就是handler.getPrototypeOf的target參數
console.log(Object.getPrototypeOf(p) === proto);    // true

5 種觸發 getPrototypeOf 代理方法的方式:

const obj = {};
const p = new Proxy(obj, {
    getPrototypeOf(target) {
        return Array.prototype;
    }
});

console.log(
    Object.getPrototypeOf(p) === Array.prototype,  // true
    Reflect.getPrototypeOf(p) === Array.prototype, // true
    p.__proto__ === Array.prototype,               // true
    Array.prototype.isPrototypeOf(p),              // true
    p instanceof Array                             // true
);

兩種異常的情況:

// getPrototypeOf() 方法返回的不是對象也不是 null
const obj = {};
const p = new Proxy(obj, {
    getPrototypeOf(target) {
        return "foo";
    }
});
Object.getPrototypeOf(p); // TypeError: "foo" is not an object or null

// 目標對象是不可擴展的,且 getPrototypeOf() 方法返回的原型不是目標對象本身的原型
const obj = Object.preventExtensions({}); // obj不可擴展
const p = new Proxy(obj, {
    getPrototypeOf(target) {
        return {};
    }
});
Object.getPrototypeOf(p); // TypeError: expected same prototype value

// 如果對上面的代碼做如下的改造就沒問題
const obj = Object.preventExtensions({}); // obj不可擴展
const p = new Proxy(obj, {
    getPrototypeOf(target) { // target就是上面的obj
        return obj.__proto__; // 返回的是目標對象本身的原型
    }
});
Object.getPrototypeOf(p); // 不報錯

一、跟瀏覽器宿主環境一致的沙箱實現

構建閉包環境

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

(function foo(){
    const a = 1;
    console.log(a);
 })();// 無法從外部訪問變量 
 
 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 執行後返回的結果。

const result = (function () {
    const name = "張三";
    return name;
})();

console.log(result); // "張三"

原生瀏覽器對象的模擬

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

eval

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

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

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

console.log(eval( this.window === window )); // true

補充幾個點:

• 性能 & 安全問題,一般不建議在實際業務代碼中引入 eval• 輔助異步編程框架的 windjs 大量採用 eval 的寫法來輔助編程,引發爭議 專訪 Wind.js 作者老趙(上):緣由、思路及發展 [11]• 瀏覽器環境下,(0, eval)() 比 eval() 的性能要好「目前已經不是了」(0, eval)(‘this’)[12]

const times = 1000;
const time1 = '直接引用';
const time2 = '間接引用';

let times1 = times;
console.time(time1);
while(times1--) {
    eval(`199 + 200`);
}
console.timeEnd(time1);

let times2 = times;
console.time(time2);
while(times2--) {
    (0, eval)(`199 + 200`);
}
console.timeEnd(time2);

new Function

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

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

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

補充幾個點:

•new Function() 性能一般比 eval 要好,很多用到這塊的前端框架都是用 new Function() 實現的,比如:Vue.js• 打開瀏覽器控制檯後,new Function() 的性能要慢一倍以上

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

const sum = new Function('a''b''return a + b'); 
console.log(sum(1, 2));//3

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

let a = 1;

function sandbox() {
    let a = 2;
    return new Function('return a;'); // 這裏的 a 指向最上面全局作用域內的 1
}

const 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 對象屬性。
    }
}

const 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 運算符能夠檢測左側操作數是否爲右側操作數的成員。其中,左側操作數是一個字符串,或者可以轉換爲字符串的表達式,右側操作數是一個對象或數組。

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

const str = `
    let a = 1; 
    window.; 
    console.log(a); // 打印:1
`;

sandbox(str)({});

console.log(window.name);//'張三'

可以看到,基於上面的方案都多多少少存在一些安全問題:

•eval 是全局對象的一個函數屬性,執行的代碼擁有着和應用中其它正常代碼一樣的的權限,它能訪問「執行上下文」中的局部變量,也能訪問所有「全局變量」,在這個場景下,它是一個非常危險的函數 • 使用 Function 構造器生成的函數,並不會在創建它的上下文中創建閉包,一般在全局作用域中被創建。當運行函數的時候,只能訪問自己的本地變量和全局變量,不能訪問 Function 構造器被調用生成的上下文的作用域 •with 一樣的問題,它首先會在傳入的對象中查找對應的變量,如果找不到就會往更上層的全局作用域去查找,所以也避免不了污染或篡改全局環境

那有沒有更安全一些的沙箱環境實現呢?

基於 Proxy 實現的沙箱 (ProxySandbox)

ES6 Proxy 用於修改某些操作的默認行爲,等同於在語言層面做出修改,屬於一種 “元編程”(meta programming)

function evalute(code,sandbox) {
  sandbox = sandbox || Object.create(null);
  const fn = new Function('sandbox'`with(sandbox){return (${code})}`);
  const proxy = new Proxy(sandbox, {
    has(target, key) {
      // 讓動態執行的代碼認爲屬性已存在
      return true; 
    }
  });
  return fn(proxy);
}
evalute('1+2') // 3
evalute('console.log(1)') // Cannot read property 'log' of undefined

我們知道無論 eval 還是 function,執行時都會把作用域一層一層向上查找,如果找不到會一直到 global,那麼利用 Proxy 的原理就是,讓執行了代碼在 sandobx 中找的到,以達到「防逃逸」的目的。

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

Symbol 對象的 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 命令排除。

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

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)
    }
}
const test = {
    a: 1,
    log(){
        console.log('11111')
    }
}
const code = 'log(); console.log(a)' // 1111,TypeError: Cannot read property 'log' of undefinedsandbox(code)(test)

Symbol.unscopables 定義對象的不可作用屬性。Unscopeable 屬性永遠不會從 with 語句中的沙箱對象中檢索,而是直接從閉包或全局範圍中檢索。

快照沙箱 (SnapshotSandbox)

快照沙箱實現來說比較簡單,主要用於不支持 Proxy 的低版本瀏覽器,原理是基於 diff 來實現的, 在子應用激活或者卸載時分別去通過快照的形式記錄或還原狀態來實現沙箱,snapshotSandbox 會污染全局 window。
我們看下 qiankun[13] 的 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);

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[14]

二、Node.js 中的沙箱實現

VM

VM 是 Node.js 默認提供的一個內建模塊,VM 模塊提供了一系列 API 用於在 V8 虛擬機環境中編譯和運行代碼。JavaScript 代碼可以被編譯並立即運行,或編譯、保存然後再運行。

const vm = require('vm');
const script = new vm.Script('m + n'); // 先new一個腳本執行的容器實例
const sandbox = { m: 1, n: 2 }; 
const context = new vm.createContext(sandbox); // 實例化一個執行上下文
const res = script.runInContext(context); // 運行
console.log(res); // 打印:3

執行上這的代碼就能拿到結果 3,同時,通過 vm.Script 還能指定代碼執行的「最大毫秒數」,超過指定的時長將終止執行並拋出一個異常:

try {
  const script = new vm.Script('while(true){}',{ timeout: 50 });
  ....
} catch (err){
  // 執行超過了50ms會打印超時的 log
  console.log(err.message);
}

上面的腳本執行將會失敗,被檢測到超時並拋出異常,然後被 Try Cache 捕獲到並打出 log,但同時需要注意的是 vm.Script 的 timeout 選項「只針對同步代有效」,而不包括是異步調用的時間,比如

  const script = new vm.Script('setTimeout(()=>{},2000)',{ timeout: 50 });

上述代碼,並不是會在 50ms 後拋出異常,因爲 50ms 上邊的代碼同步執行肯定完了,而 setTimeout 所用的時間並不算在內,也就是說 vm 模塊沒有辦法對異步代碼直接限制執行時間。我們也不能額外通過一個 timer 去檢查超時,因爲檢查了執行中的 vm 也沒有方法去中止掉。

另外,在 Node.js 通過 vm.runInContext 看起來似乎隔離了代碼執行環境,但實際上卻很容易「逃逸」出去。我們看下這個過程。

使用 VM 模塊我們可以在獨立的環境中運行不受信任的代碼,這就意味着運行在沙箱裏的代碼不能訪問 Node 進程了,對嗎?

基本的使用示例代碼:

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`let a = "welcome!";a;`);
console.log(xyz); // a

現在我們嘗試訪問進程

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`process`);
console.log(xyz);

“process is not defined”,所以默認情況下 VM 模塊不能訪問主進程,如果想要訪問需要指定授權。
看起來默認不能訪問 “process、require” 等就滿足需求了,但是真的就沒有辦法觸及主進程並執行代碼了?
看下面的例子:

"use strict";
const vm = require("vm");
const xyz = vm.runInNewContext(`this.constructor.constructor('return this.process.env')()`);
console.log(xyz);

在 javascript 中 this 指向它所屬的對象,所以我們使用它時就已經指向了一個 VM 上下文之外的對象。那麼訪問 this 的. constructor 就返回 Object Constructor ,訪問 Object Constructor 的 .constructor 返回 Function constructor 。
Function constructor 就像 javascript 提供的最高函數,他可以訪問全局,所以他能返回全局事物。Function constructor 允許從字符串生成函數,從而執行任意代碼。
所以我們可以使用 Function constructor 返回主進程。關於 Function constructor 更多內容在這裏 [15] 和這裏 [16]。

可以正常打印,也就是說順利拿到了主進程的 process,也就是上面所說的產生了「逃逸」。這招同樣對突破 Angular 同樣有效 —— AngularJS 沙箱 [17]。

再看下面的例子:

const vm = require('vm');
const sandbox = {};
const script = new vm.Script('this.constructor.constructor("return process")().exit()');
const context = vm.createContext(sandbox);
script.runInContext(context);

執行上邊的代碼,宿主程序立即就會「退出」,sandbox 是在 VM 之外的環境創建的,需 VM 中的代碼的 this 指向的也是 sandbox,那麼

//this.constructor 就是外所的 Object 構建函數
const ObjConstructor = this.constructor; 
//ObjConstructor 的 constructor 就是外包的 Function
const Function = ObjConstructor.constructor;
//創建一個函數,並執行它,返回全局 process 全局對象
const process = (new Function('return process'))(); 
//退出當前進程
process.exit();

沒有人願意用戶一段腳本就能讓應用掛掉吧。除了退出進程序之外,實際上還能幹更多的事情。
有個簡單的方法就能避免通過 this.constructor 拿到 process,如下:

const vm = require('vm');
//創建一外無 proto 的空白對象作爲 sandbox
// const sandbox = {}; // 能通過this.constructor 拿到 process
const sandbox = Object.create(null); // 這樣就能防止this.constructor 拿到 process
const script = new vm.Script('this.constructor.constructor("return process")()');
const context = vm.createContext(sandbox);
const nodeProcess = script.runInContext(context);
console.log(nodeProcess);

但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文檔中也提到「 不要把 VM 當做一個安全的沙箱,去執行任意非信任的代碼」。

VM2

在社區中有一些開源的模塊用於運行不信任代碼,例如 sandbox、vm2、jailed 等。相比較而言 vm2 對各方面做了更多的安全工作,相對安全些。「這也是爲什麼 imageCook 採用了該沙箱模塊」

從 vm2 的官方 README 中可以看到,它基於 Node.js 內建的 VM 模塊,來建立基礎的沙箱環境,然後同時使用上了文介紹過的 ES6 的 Proxy 技術來防止沙箱腳本逃逸。

用同樣的測試代碼來試試 vm2:

const { VM } = require('vm2');
new VM().run('this.constructor.constructor("return process")().exit()');

如上代碼,並沒有成功結束掉宿主程序,vm2 官方 REAME 中說「vm2 是一個沙盒,可以在 Node.js 中安全的執行不受信任的代碼」。

然而,事實上我們還是可以幹一些「壞」事情,比如:

只要能幹壞事情,就是不安全的

const { VM } = require('vm2');
const vm = new VM({ timeout: 1000, sandbox: {}});
vm.run('new Promise(()=>{})');

上邊的代碼將永遠不會執行結束,如同 Node.js 內建模塊一樣,vm2 的 timeout 對異步操作是無效的。同時,vm2 也不能額外通過一個 timer 去檢查超時,因爲它也沒有辦法將執行中的 vm 終止掉。這會一點點耗費完服務器的資源,讓你的應用掛掉。

那麼或許你會想,我們能不能在上邊的 sandbox 中放一個假的 Promise 從而禁掉 Promise 呢?答案是能提供一個「假」的 Promise,但卻沒有辦法完成禁掉 Promise,比如

const { VM } = require('vm2');
const vm = new VM({ 
  timeout: 1000, 
  sandbox: { Promise: function(){}}
});

vm.run('Promise = (async function(){})().constructor;new Promise(()=>{});');

可以看到通過一行 Promise = (async function(){})().constructor 就可以輕鬆再次拿到 Promise 了。從另一個層面來看,況且或許有時我們還想讓自定義腳本支持異步處理呢。

關於 VM2 還有更多新的和創新性的繞過 ——更多逃逸 [18]。

除了從沙箱逃逸,還可以使用 infinite while loop 創建無限循環拒絕服務。

const {VM} = require('vm2');

new VM({timeout:1}).run(`
    function main(){
        while(1){}
    }
    
    new Proxy({}{
        getPrototypeOf(t){
            global.main();
        }
    })`
);

Safeify[19]:Node.js 環境下建立一個更安全的沙箱

通過上文的探究,我們並沒有找到一個完美的方案在 Node.js 建立安全的隔離的沙箱。其中 vm2 做了不少處理,相對來講算是較安全的方案了,但問題也很明顯,比如異步不能檢查超時的問題以及和宿主程序在相同進程的問題。

沒有進程隔離時,通過 VM 創建的 sanbox 大體是這樣的

那麼,我們是不是可以嘗試,將非受信代碼,通過 vm2 這個模塊隔離在一個獨立的進程中執行呢?然後,執行超時時,直接將隔離的進程幹掉,但這裏我們需要考慮如下幾個問題:

通過進程池統一調度管理沙箱進程

如果來一個執行任務,創建一個進程,用完銷燬,僅處理進程的開銷就已經稍大了,並且也不能不設限的開新進程和宿主應用搶資源,那麼,需要建一個進程池:
前提:所有任務到來會創建一個 Script 實例,先進入一個 pending 隊列,然後直接將 script 實例的 defer 對象返回,調用處就能 await 執行結果了
然後:由 sandbox master 根據工程進程的空閒程序來調度執行,master 會將 script 的執行信息,包括重要的 ScriptId,發送給空閒的 worker,worker 執行完成後會將「結果 + script 信息」回傳給 master,master 通過 ScriptId 識別是哪個腳本執行完畢了,就是結果進行 resolve 或 reject 處理。

這樣,通過「進程池」既能降低「進程來回創建和銷燬的開銷」,也能確保不過度搶佔宿主資源;同時,在異步操作超時,還能將工程進程直接殺掉;同時,master 將發現一個工程進程掛掉,會立即創建替補進程。

處理的數據和結果公開給沙箱的方法

進程間如何通訊,需要「動態代碼」操作數據後可以直接序列化然後通過 IPC 發送給隔離 Sandbox 進程,執行結果一樣經過序列化通過 IPC 傳輸。

其中,如果想公開一個方法給 sandbox,因爲不在一個進程,並不能方便的將一個方案的引用傳遞給 sandbox。我們可以將宿主的方法,在傳遞給 sandbox worker 之類做一下處理,轉換爲一個「描述對象」,包括了允許 sandbox 調用的方法信息,然後將信息,如同其它數據一樣發送給 worker 進程,worker 收到數據後,識別出「方法描述對象」,然後在 worker 進程中的 sandbox 對象上建立代理方法,代理方法同樣通過 IPC 和 master 通訊。

針對沙箱進程進行 CPU 和內存配額限制

在 Linux 平臺,通過 CGroups 對沙箱進程進行整體的 CPU 和內存等資源的配額限制,CGroups 是 Control Groups 的縮寫,是 Linux 內核提供的一種可以限制、記錄、隔離進程組(Process Groups)所使用的物理資源(如:CPU、Memory、IO 等等)的機制。最初由 Google 的工程師提出,後來被整合進 Linux 內核。CGroups 也是 LXC 爲實現虛擬化所使用的資源管理手段,可以說沒有 CGroups 就沒有 LXC。

最終,我們建立了一個大約這樣的「沙箱環境」

如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,基於這些處理被封裝爲一個獨立的模塊 Safeify[20],在 Github 上已經開源。

相較於內建的 VM 及常見的幾個沙箱模塊, Safeify 具有如下特點:

• 爲將要執行的動態代碼建立專門的進程池,與宿主應用程序分離在不同的進程中執行 • 支持配置沙箱進程池的最大進程數量 • 支持限定同步代碼的最大執行時間,同時也支持限定包括異步代碼在內的執行時間 • 支持限定沙箱進程池的整體的 CPU 資源配額(小數)• 支持限定沙箱進程池的整體的最大的內存限制(單位 m)

簡單介紹一下 Safeify 如何使用,通過如下命令安裝

npm i safeify --save

在應用中使用,還是比較簡單的,如下代碼(TypeScript 中類似)

import { Safeify } from 'safeify';

const safeVm = new Safeify({
  timeout: 50,          //超時時間,默認 50ms
  asyncTimeout: 500,    //包含異步操作的超時時間,默認 500ms
  quantity: 4,          //沙箱進程數量,默認同 CPU 核數
  memoryQuota: 500,     //沙箱最大能使用的內存(單位 m),默認 500m
  cpuQuota: 0.5,        //沙箱的 cpu 資源配額(百分比),默認 50%
});

const context = {
  a: 1, 
  b: 2,
  add(a, b) {
    return a + b;
  }
};

const rs = await safeVm.run(`return add(a, b)`, context);
console.log('result',rs);

關於安全的問題,沒有最安全,只有更安全。Safeify 已在部分項目中使用,但自定義腳本的功能是往往僅針對內網用戶,有不少動態執行代碼的場景其實是可以避免的,繞不開或實在需要提供這個功能時,希望本文或 Safeify 能對大家有所幫助就行了。

結論

運行不信任的代碼是非常困難的,只依賴軟件模塊作爲沙箱技術,防止不受信任代碼用於非正當用途是不得已的決定。這可能促使雲上 SAAS 應用的不安全,因爲通過逃逸出沙箱進程多個租戶間的數據可能被訪問(主進程數據獲取),這樣你就可能可以通過 session,secret 等來潛入其他租戶。一個更安全的選擇是依賴於硬件虛擬化,比如每個租戶代碼在獨立的 docker 容器或 AWS Lambada Function 中執行會是更好的選擇。

下面是 Auth0 如何處理沙箱問題:Sandboxing Node.js with CoreOS and Docker[21]。「下來可以再詳細研究下實現」

三、看一個 case

imageCook 的使用 case

目標:拿到用於前端頁面渲染的 index.js + index.css

基本思路:

• 模板代碼生成代碼:https://github.com/imgcook-dsl/react-xt-standard/blob/master/src/index.js• 基於 Group / 倉庫名可以拿到整個倉庫的所有代碼 •gitlab 的代碼拉取實現方式可以參考:針對字節現狀封裝的 Gitlab API[22] 「使用了 Node.js 的混合流」•github 的代碼拉取可以參考:https://www.npmjs.com/package/download-git-repo 曾被 vue-cli 2.x[23] 版本使用

{
    "package.json""xxx",
    "src/index.js""yyy"
}

• 拿到執行函數字符串

module.exports = function(schema, option) {
  let imgNumber = 0;

  const {prettier} = option;
  ...
  };

•Node.js 沙箱執行,得到上面函數返回的字符串

import { Safeify } from 'safeify';
import { getRepoProjectEntries } from 'byte-gitlab';

const safeVm = new Safeify({
  timeout: 50,          // 超時時間,默認 50ms
  asyncTimeout: 500,    // 包含異步操作的超時時間,默認 500ms
  quantity: 4,          // 沙箱進程數量,默認同 CPU 核數
  memoryQuota: 500,     // 沙箱最大能使用的內存(單位 m),默認 500m
  cpuQuota: 0.5,        // 沙箱的 cpu 資源配額(百分比),默認 50%
});

const context = {
   schema: {}, 
   option: {}
};

(async () ={
  const zipStream = await getRepoProjectEntries({
    group: 'mordor',
    project: 'lynx-standard',
    branch: 'master'
  });
  
  zipStream
    .pipe(async (contents: string, path: string) ={
        const rs = await safeVm.run(contents, context);
        console.log('result', rs);
        
        return rs;
    })
    .pipe(this.emitDone())
    .once("done"done)
    .once("error"(err) ={
      console.log("流執行出錯統一監控:".red, err);
    });
})();

• 返回給客戶端

關於 CSS 隔離

常見的有,不再贅述:•CSS Module•namespace•Dynamic StyleSheet•css in js•Shadow DOM

引用鏈接

[1] 沙箱(Sandbox): http://www.arkteam.net/?p=2967
[2] JavaScript 中 constructor 屬性: https://segmentfault.com/a/1190000013245739
[3] class: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/class
[4] Class 構造方法: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Classes/constructor
[5] Object.getPrototypeOf(): https://www.axihe.com/api/js-es/ob-object/get-prototype-of.html
[6] Reflect.getPrototypeOf(): https://www.axihe.com/api/js-es/ob-reflect/get-prototype-of.html
[7] protohttps://www.axihe.com/api/js-es/ob-object/proto.html
[8] Object.prototype.isPrototypeOf(): https://www.axihe.com/api/js-es/ob-object/is-prototype-of.html
[9] instanceof: https://www.axihe.com/api/js-es/ex-relational/instanceof.html
[10] TypeError: https://www.axihe.com/api/js-es/ob-error/type-error.html
[11] 專訪 Wind.js 作者老趙(上):緣由、思路及發展: https://www.infoq.cn/article/interview-jscex-author-part-1
[12] (0, eval)(‘this’): https://www.cnblogs.com/qianlegeqian/p/3950044.html
[13] qiankun: https://qiankun.umijs.org/zh/guide
[14] :proxySandbox: https://link.segmentfault.com/?enc=Mb%2BNNJjUrmTA7g2uf%2FTgzQ%3D%3D.IHwAeHwf8%2FPDd3WJLo%2F4dCWf2md2lzw7s%2BIEdUcUHmX7xMSccEguXX%2BFQBtpgU8SHiyqxgnCi00SvzmT95eNTRD1XaOHjO5xokQrsy%2BHYtQ%3D
[15] 這裏: https://link.juejin.cn/?target=http%3A%2F%2Fdfkaye.github.io%2F2014%2F03%2F14%2Fjavascript-eval-and-function-constructor%2F
[16] 這裏: https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fi0natan%2Fnodebestpractices%2Fissues%2F211
[17] AngularJS 沙箱: https://link.juejin.cn/?target=https%3A%2F%2Fportswigger.net%2Fresearch%2Fdom-based-angularjs-sandbox-escapes
[18] 更多逃逸: https://github.com/patriksimek/vm2/issues?q=is%3Aissue+author%3AXmiliaH+is%3Aclosed
[19] Safeify: https://github.com/Houfeng/safeify
[20] Safeify: https://github.com/Houfeng/safeify
[21] Sandboxing Node.js with CoreOS and Docker: https://link.juejin.cn/?target=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Du81pS05W1JY
[22] 針對字節現狀封裝的 Gitlab API: https://code.byted.org/toutiao-fe/logic-monitor-server/blob/dev/init/app/service/gitlab.ts
[23] 2.x: https://github.com/vuejs/vue-cli/blob/v2.9.3/package.json

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6HlZjhvoTQxZbP1RrgNsEg