聊聊 QianKun JS 沙箱的那些事

發表於2023-09-26

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:空山

什麼是沙箱

沙箱即 SandBox,它是一種安全機制,用於嚴格控制訪問資源。透過在程式中建立一個獨立的執行環境,把一些來源不可信、具有破壞力或者又是無法判定的惡意程式使其在該環境下執行,隔離了對外部程式的影響,這樣即使發生了錯誤或者安全問題都不會影響到外面。
我們根據實現的方案不同, SandBox可以分為兩種模式:

  • 單例項模式:全域性只存在一個例項,直接代理原生的window物件,記錄每個沙箱內window物件上的增刪改等操作,啟用某個沙箱時恢復上一次失活時的狀態,失活時恢復原來window的狀態。
  • 多例項模式:代理一個全新的window物件,所有的更改基於這個全新的物件,多個例項之間互不影響。

沙箱的應用場景

基於上面?對沙箱的介紹,簡而言之我們最終的目的還是為了保障程式的正常執行,透過隔離的手段避免錯誤、異常或者惡意程式碼的影響,在我們日常開發或者接觸中,也有很多這樣的場景,以下列舉幾個:

  • 微前端:微前端場景下,各個子應用被整合到一個執行時,避免每個子應用互相影響,導致的一些如全域性汙染的問題,下面?會以QianKun為例進行詳細的講述
  • JSONP:當執行透過 <script>標籤的url返回的JS程式碼時,為了規避一定程度上的風險可能需要在沙箱中執行
  • 線上編輯器:在某些場景下我們會提供一個編輯器或者類似的可輸入介面需要使用者自主的編輯程式碼,然後去執行它,比如:CodeSandBox對於使用者輸入的不確定程式碼為了防止汙染最好是在沙箱中執行

我們把它們進行抽象的歸類,大概可以分為以下三類:

  • 執行時:執行不確定、不可信的JS程式碼
  • 引入時:為引入的JS程式碼提供隔離環境
  • 訪問時:執行程式碼對全域性物件的訪問和修改進行限制

JS沙箱的常見解決方案

在實現JS沙箱問題之前我們需要有兩點需要注意:

  • 構建獨立的上下文環境
  • 模擬瀏覽器的原生物件

基於這兩點,目前給出以下幾種方案:

with

with語句將改變作用域,會讓內部的訪問優先從傳入的物件上查詢。怎麼理解呢,我們來看一下這一段程式碼:

const obj = {
  a: 1
}
const obj2 = {
  b: 2
}
const a = 9

const fun = (obj) => {
  with(obj) { // 相當於{}內訪問的變數都會從obj上查詢
    console.log(a)
    a = 3
  }
}

fun(obj) // 1
console.log(obj) // { a: 3 }

在當前的內部環境中找不到某個變數時,會沿著作用作用域鏈一層層向上查詢,如果找不到就丟擲ReferenceError異常。我們看下下面這個例子:

const obj = {
  a: 1
}
const obj2 = {
  b: 2
}
const b = 9

const fun = (obj) => {
  with(obj) {
    console.log(a, b)
  }
}

fun(obj) // 1 9
fun(obj2) // ReferenceError: a is not defined

雖然with實現了在當前上下文中查詢變數的效果,但是仍然存在一下問題:

  • 找不到時會沿著作用域鏈往上查詢
  • 當修改存在的變數時,會同步修改外層的變數

除此之外with還有其他的一些弊端?詳細瞭解

ES6 proxy

為了解決with存在的問題,我們來了解下proxy方法。proxy用於建立一個物件的代理,從而實現對基本操作的攔截以及自定義。

基本語法

/**
* @param {*} target - 使用Proxy包裝的目標物件
* @param {*} handler - 通常為一個函式,函式的各個屬性分別定義了執行各個操作時的代理行為
*/
const p = new Proxy(target, handler)

?詳細瞭解

改進流程
file

code實現

const obj = {
  a: 1
}
const obj2 = {
  b: 2
}
let b = 3

// 用with改變作用域
const withedCode = (code) => {
  // return (obj)  => {
  //   with(ctxProxy(obj)){
  //     eval(code)
  //   }
  // }
  code = `with(obj) { ${ code } }`
  const fun = new Function('obj', code)
  return fun
}

// 執行程式碼
const code = 'console.log(b)'

// 白名單
const whiteList = ['console', 'b']

// // 訪問攔截
const ctxProxy = (ctx) => new Proxy(ctx, {
  has: (target, prop) => { // 當返回false的時候會沿著作用域向上查詢,true為在當前作用域進行查詢
    if(whiteList.includes(prop)) { 
      return false
    }
    if(!target.hasOwnProperty(prop)) { 
      throw new Error(`can not find - ${prop}!`)
    }
    return true
  },
})

withedCode(code)(ctxProxy(obj2)) // 3

思考?:為啥需要把console新增到whiteList中?

Tips:該案例在瀏覽器中執行正常,在node中執行可能出現問題,原因是使用了new Function,建立的函式只能在全域性作用域中執行,而在node中頂級的作用域不是全域性作用域,當前全域性宣告的變數是在當前模組的作用域裡的。詳細檢視:Function

這樣一個簡單的沙箱是不是完成了,那我們現在會不會想這樣一個問題?
解決完物件的訪問控制,我們現在解決第二個問題如何模擬瀏覽器的全域性物件———iframe

還有人不清楚為啥需要模擬瀏覽器的物件嗎??

  • 一些全域性物件方法的使用比如上面的console.log
  • 獲取全域性物件中的一些初始變數

with + proxy + iframe

我們把原生瀏覽器的物件取出來

const iframe = document.createElement('iframe')
document.body.append(iframe)
const globalObj = iframe.contentWindow

建立一個全域性代理物件的類

class GlobalProxy{
    constructor(shareState) {
        return new Proxy(globalObj, {
            has: (target, prop) => {
                if(shareState.includes(prop)) {
                    return false
                }
                if(!target.hasOwnProperty(prop)) {
                    throw new Error(`can not find - ${prop}!`)
                }
                return true
            }
        })
    }
}

實際效果:

// 建立共享白名單
const shareState = []

// 建立一個沙箱例項
const sandBox = new GlobalProxy(shareState)

const withedCode = (code) => {
    code = `with(obj) { ${ code } }`
    const fun = new Function('obj', code)
    return fun
}

sandBox.abc = 123

// 執行程式碼
const code = 'console.log(abc)'

withedCode(code)(sandBox)
console.log(abc)

//------console------
// 123
// undefined

Web Workers

透過建立一個獨立的瀏覽器執行緒來達到隔離的目的,但是具有一定的侷限性

  • 不能直接操作DOM節點
  • 不能使用windowwindow物件的預設方法和屬性

......
原因在於workers執行在另一個全域性上下文中,不同於當前的window。
因此適用的場景大概類似於一些表示式的計算等。

通訊方式

workers和主執行緒之間透過訊息機制進行資料傳遞

  • postMessage——傳送訊息
  • onmessage——處理訊息

我們來簡單看個例子:

// index.js
window.app = '我是後設資料'
const myWorker = new Worker('worker.js')

myWorker.onmessage = (oEvent) => {
  console.log('Worker said : ' + oEvent.data)
}
myWorker.postMessage('我是主執行緒!')

// worker.js
postMessage('我是子執行緒!');
onmessage = function (oEvent) {
  postMessage("Hi " + oEvent.data);
	console.log('window', window)
	console.log('DOM', document)
}

// -------------console-------------
// Worker said : 我是子執行緒!
// Worker said : Hi 我是主執行緒!
// Uncaught ReferenceError: window is not defined

?詳細瞭解

沙箱逃逸

沙箱逃逸即透過各種手段擺脫沙箱的束縛,訪問沙箱外的全域性變數甚至是篡改它們,實現一個沙箱還需要預防這些情況的發生。

Symbol.unscopables

Symbol.unscopables設定了true會對with進行無視,沿著作用域進行向上查詢。
舉個例子:

const obj = {
    a: 1
}

let a = 10

obj[Symbol.unscopables] = {
    a: true
}

with(obj) {
    console.log(a) // 10
}

改進上述with + proxy + iframe中的全域性代理物件的類

class GlobalProxy{
    constructor(shareState) {
        return new Proxy(globalObj, {
            has: (target, prop) => {
                if(shareState.includes(prop)) {
                    return false
                }
                if(!target.hasOwnProperty(prop)) {
                    throw new Error(`can not find - ${prop}!`)
                }
                return true
            },
            get: (target, prop) => {
								// 處理Symbol.unscopables逃逸
                if(prop === Symbol.unscopables) return undefined
                return target[prop]
            }
        })
    }
}

window.parent

可以在沙箱的執行上下文中透過該方法拿到外層的全域性物件

const iframe = document.createElement('iframe')
document.body.append(iframe)
const globalObj = iframe.contentWindow

class GlobalProxy{
    constructor(shareState) {
        return new Proxy(globalObj, {
            has: (target, prop) => {
                if(shareState.includes(prop)) {
                    return false
                }
                if(!target.hasOwnProperty(prop)) {
                    throw new Error(`can not find - ${prop}!`)
                }
                return true
            },
            get: (target, prop) => {
                // 處理Symbol.unscopables逃逸
                if(prop === Symbol.unscopables) return undefined
                
                return target[prop]
            }
        })
    }
}

// 建立共享白名單
const shareState = []

// 建立一個沙箱例項
const sandBox = new GlobalProxy(shareState)

const withedCode = (code) => {
    code = `with(obj) { ${ code } }`
    const fun = new Function('obj', code)
    return fun
}

sandBox.abc = 123
sandBox.aaa = 789

sandBox[Symbol.unscopables] = {
    aaa: true
}

var aaa = 123

// 執行程式碼
const code = 'console.log(parent.test = 789)'

withedCode(code)(sandBox)
console.log(window.test) // 789

改進方案

get: (target, prop) => {
    // 處理Symbol.unscopables逃逸
    if(prop === Symbol.unscopables) return undefined
		// 阻止window.parent逃逸
    if(prop === 'parent') {
        return target
    }
    return target[prop]
}

原型鏈逃逸

透過某個變數的原型鏈向上查詢,從而達到篡改全域性物件的目的

const code = `([]).constructor.prototype.toString = () => {
    return 'Escape!'
}`

console.log([1,2,3].toString()) // Escape!

……

未來可嘗試的新方案

ShadowRealms

它是未來JS的一項功能,目前已經進入stage-3。透過它我們可以建立一個單獨的全域性上下文環境來執行JS。
關於ShadowRealms的更多詳情:

Portals

類似於iframe的新標籤。
關於portals的更多詳情

探究QianKun中的沙箱

有了上面的知識儲備以後,讓我們來看看QianKun中的沙箱是怎麼樣子的,以下只講述一些關鍵程式碼,原始碼地址:https://github.com/umijs/qiankun/tree/master/src/sandbox,版本為v2.6.3
我們進入index檔案看下

// 是否支援Proxy代理
if (window.Proxy) {
  sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
} else {
  sandbox = new SnapshotSandbox(appName);
}

我們可以看到QianKun裡的沙箱主要分為三種

  • LegacySandbox:單例項代理沙箱,簡單來講就是隻存在一個window例項,所有的操作都是對這一個例項的操作
  • ProxySandbox:多例項代理沙箱,透過對window的複製建立多個副本,在沙箱中對建立的副本進行操作
  • SnapshotSandbox:快照沙箱,基於 diff 方式實現的沙箱,用於不支援 Proxy 的低版本瀏覽器

SnapshotSandbox

我們先來看下SnapshotSandbox這個沙箱,原始碼:

// 遍歷物件
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}

/**
 * 基於 diff 方式實現的沙箱,用於不支援 Proxy 的低版本瀏覽器
 */
export default class SnapshotSandbox implements SandBox {
  proxy: WindowProxy;

  name: string;

  type: SandBoxType;

  sandboxRunning = true;

  private windowSnapshot!: Window;

  private modifyPropsMap: Record<any, any> = {};

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    this.type = SandBoxType.Snapshot;
  }

  active() {
    // 記錄當前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢復之前的變更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      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];
      }
    });

    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }

    this.sandboxRunning = false;
  }
}

結合流程圖
file
分塊解讀一下,這裡我們把它分成兩塊來看

啟用時

active() {
  this.windowSnapshot = {} as Window;
  iter(window, (prop) => { // 透過遍歷的方式記錄當前window的狀態,即window的當前快照
    this.windowSnapshot[prop] = window[prop];
  });

  Object.keys(this.modifyPropsMap).forEach((p: any) => { // 
    window[p] = this.modifyPropsMap[p]; // 透過遍歷恢復上一次失活時的變更
  });

  this.sandboxRunning = true;
}

失活時

inactive() {
  this.modifyPropsMap = {};

  iter(window, (prop) => { // 遍歷window上的屬性
    if (window[prop] !== this.windowSnapshot[prop]) {
      this.modifyPropsMap[prop] = window[prop]; // 記錄和快照不一致的屬性到修改的物件中
      window[prop] = this.windowSnapshot[prop]; // 恢復window的屬性為初始的屬性
    }
  });

  this.sandboxRunning = false;
}

SnapshotSandbox比較簡單,由於不支援代理,所有的更改都在window上,只是在啟用沙箱的時候儲存一個window的初始快照,並在期間對變更的屬性進行記錄,失活時恢復初始的window,但是會造成全域性window的汙染。

LegacySandbox

我們來看下LegacySandbox沙箱,該沙箱基於Proxy實現的
流程圖
image.png
原始碼部分

// 判斷物件上的某個屬性描述是否是可更改或者是可刪除的
function isPropConfigurable(target: WindowProxy, prop: PropertyKey) {
  const descriptor = Object.getOwnPropertyDescriptor(target, prop);
  return descriptor ? descriptor.configurable : true;
}

/**
 * 基於 Proxy 實現的沙箱
 * TODO: 為了相容性 singular 模式下依舊使用該沙箱,等新沙箱穩定之後再切換
 */
export default class LegacySandbox implements SandBox {
  /** 沙箱期間新增的全域性變數 */
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  /** 沙箱期間更新的全域性變數 */
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  /** 持續記錄更新的(新增和修改的)全域性變數的 map,用於在任意時刻做 snapshot */
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  name: string;

  proxy: WindowProxy;

  globalContext: typeof window;

  type: SandBoxType;

  sandboxRunning = true;

  latestSetProp: PropertyKey | null = null;

	// 設定globalContext物件上的屬性
  private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
    if (value === undefined && toDelete) {
      delete (this.globalContext as any)[prop];
    } else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
      Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
      (this.globalContext as any)[prop] = value;
    }
  }

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

    this.sandboxRunning = true;
  }

  inactive() {
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

  constructor(name: string, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.LegacyProxy;
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;

    const rawWindow = globalContext;
    const fakeWindow = Object.create(null) as Window;

    const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
      if (this.sandboxRunning) {
        if (!rawWindow.hasOwnProperty(p)) {
          addedPropsMapInSandbox.set(p, value);
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
          // 如果當前 window 物件存在該屬性,且 record map 中未記錄過,則記錄該屬性初始值
          modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
        }

        currentUpdatedPropsValueMap.set(p, value);

        if (sync2Window) {
          // 必須重新設定 window 物件保證下次 get 時能拿到已更新的資料
          (rawWindow as any)[p] = value;
        }

        this.latestSetProp = p;

        return true;
      }

      // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤
      return true;
    };

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        const originalValue = (rawWindow as any)[p];
        return setTrap(p, value, originalValue, true);
      },

      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // or use window.top to check if an iframe context
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }

        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(_: Window, p: string | number | symbol): boolean {
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },

      defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean {
        const originalValue = (rawWindow as any)[p];
        const done = Reflect.defineProperty(rawWindow, p, attributes);
        const value = (rawWindow as any)[p];
        setTrap(p, value, originalValue, false);

        return done;
      },
    });

    this.proxy = proxy;
  }
}

同樣的我們來分塊解讀一下
這裡有三個主要的變數,我們需要先知道下

/** 沙箱期間新增的全域性變數 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();

/** 沙箱期間更新的全域性變數,記錄的是啟用子應用時window上的初始值 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

/** 持續記錄更新的(新增和修改的)全域性變數的 map,用於在任意時刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

// 白名單,是微應用之間全域性共享的變數
const variableWhiteList: PropertyKey[] = [
  'System',
  '__cjsWrapper',
  ...variableWhiteListInDev,
]

啟用時

active() {
  if (!this.sandboxRunning) {
		// 把上一次沙箱啟用時的變更,設定到window上
    this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
  }
  this.sandboxRunning = true;
}

失活時

inactive() {
  ...
	// 透過遍歷還原window上的初始值
  this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
	// 透過對新增屬的遍歷,去除window上新增的屬性
	this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
  this.sandboxRunning = false;
}

Proxy代理

const proxy = new Proxy(fakeWindow, {
	set: (_: Window, p: PropertyKey, value: any): boolean => {
	  const originalValue = (rawWindow as any)[p];
		// 把變更的屬性同步到addedPropsMapInSandbox、modifiedPropsOriginalValueMapInSandbox以及currentUpdatedPropsValueMap
	  return setTrap(p, value, originalValue, true);
	},
	
	get(_: Window, p: PropertyKey): any {
		// 防止透過使用top、parent、window、self訪問外層真實的環境
	  if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
	    return proxy;
	  }
	
	  const value = (rawWindow as any)[p];
		//一些異常處理獲取value的真實值,主要處理了window.console、window.atob這類API在微應用中呼叫時會丟擲 Illegal invocation異常的問題
	  return getTargetValue(rawWindow, value);
	},
	
	has(_: Window, p: string | number | symbol): boolean {
		// 訪問的屬性是否在rawWindow上,不在返回false沿著作用域向上查詢
	  return p in rawWindow;
	},
	
	// 攔截物件的Object.getOwnPropertyDescriptor()操作
	getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
	  const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
	  // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
	  if (descriptor && !descriptor.configurable) {
	    descriptor.configurable = true;
	  }
	  return descriptor;
	},
	
	// 攔截物件的Object.defineProperty()操作
	defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean {
	  const originalValue = (rawWindow as any)[p];
	  const done = Reflect.defineProperty(rawWindow, p, attributes);
	  const value = (rawWindow as any)[p];
	  setTrap(p, value, originalValue, false); // 變更屬性記錄
	
	  return done;
	},
});

const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
  if (this.sandboxRunning) {
		// 判斷是否為rawWindow自己的屬性
    if (!rawWindow.hasOwnProperty(p)) {
			// 新增的屬性存入addedPropsMapInSandbox
      addedPropsMapInSandbox.set(p, value);
    } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
      // 修改的屬性把初始值存入modifiedPropsOriginalValueMapInSandbox中
      modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
    }
		
		// 當前資料的所有變更記錄在currentUpdatedPropsValueMap中
    currentUpdatedPropsValueMap.set(p, value);

    if (sync2Window) {
      // 必須重新設定 window 物件保證下次 get 時能拿到已更新的資料
      (rawWindow as any)[p] = value;
    }

    this.latestSetProp = p;

    return true;
  }

  // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤
  return true;
}

仍然是操作window物件,會造成全域性window的汙染,但是不需要記錄window的初始快照,也不需要對window進行自身屬性的整個遍歷相比於diff快照效率會高點,效能會好點。

ProxySandbox

接下來我們來看下ProxySandbox這個沙箱,該沙箱透過建立一個window的副本fakeWindow實現每個ProxySandbox例項之間屬性互不影響
流程圖
image.png
首先我們需要建立一個window副本fakeWindowpropertiesWithGetter,後一個是用來記錄有getter且不可配置的Map物件,具體實現參考proxy中的get部分

function createFakeWindow(globalContext: Window) {
	// 記錄 window 物件上的 getter 屬性,原生的有:window、document、location、top
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  Object.getOwnPropertyNames(globalContext)
		// 遍歷出window上所有不可配置的屬性
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
			// 獲取屬性描述符
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
        if (
          p === 'top' ||
          p === 'parent' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          descriptor.configurable = true;
          
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);
				// 凍結某個屬性,凍結以後該屬性不可修改
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

ProxySandbox沙箱原始碼:

export default class ProxySandbox implements SandBox {
  /** window 值變更記錄 */
  private updatedValueSet = new Set<PropertyKey>();

  name: string;

  type: SandBoxType;

  proxy: WindowProxy;

  globalContext: typeof window;

  sandboxRunning = true;

  latestSetProp: PropertyKey | null = null;

  private registerRunningApp(name: string, proxy: Window) {
    if (this.sandboxRunning) {
      const currentRunningApp = getCurrentRunningApp();
      if (!currentRunningApp || currentRunningApp.name !== name) {
        setCurrentRunningApp({ name, window: proxy });
      }
      // FIXME if you have any other good ideas
      // remove the mark in next tick, thus we can identify whether it in micro app or not
      // this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
      nextTask(() => {
        setCurrentRunningApp(null);
      });
    }
  }

  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }

  inactive() {
    if (--activeSandboxCount === 0) {
      variableWhiteList.forEach((p) => {
        if (this.proxy.hasOwnProperty(p)) {
          // @ts-ignore
          delete this.globalContext[p];
        }
      });
    }

    this.sandboxRunning = false;
  }

  constructor(name: string, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;

    const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);

    const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
    const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          this.registerRunningApp(name, proxy);
          // We must kept its description while the property existed in globalContext before
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable } = descriptor!;
            if (writable) {
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            // @ts-ignore
            target[p] = value;
          }

          if (variableWhiteList.indexOf(p) !== -1) {
            // @ts-ignore
            globalContext[p] = value;
          }

          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 會丟擲 TypeError,在沙箱解除安裝的情況下應該忽略錯誤
        return true;
      },

      get: (target: FakeWindow, p: PropertyKey): any => {
        this.registerRunningApp(name, proxy);

        if (p === Symbol.unscopables) return unscopables;
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        // hijack globalWindow accessing with globalThis keyword
        if (p === 'globalThis') {
          return proxy;
        }

        if (
          p === 'top' ||
          p === 'parent' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          // if your master app in an iframe context, allow these props escape the sandbox
          if (globalContext === globalContext.parent) {
            return proxy;
          }
          return (globalContext as any)[p];
        }

        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        if (p === 'document') {
          return document;
        }

        if (p === 'eval') {
          return eval;
        }

        const value = propertiesWithGetter.has(p)
          ? (globalContext as any)[p]
          : p in target
          ? (target as any)[p]
          : (globalContext as any)[p];
        /* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
           See this code:
             const proxy = new Proxy(window, {});
             const proxyFetch = fetch.bind(proxy);
             proxyFetch('https://qiankun.com');
        */
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return getTargetValue(boundTarget, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(target: FakeWindow, p: string | number | symbol): boolean {
        return p in unscopables || p in target || p in globalContext;
      },

      getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
        /*
         as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
         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.
         */
        if (target.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(target, p);
          descriptorTargetMap.set(p, 'target');
          return descriptor;
        }

        if (globalContext.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
          descriptorTargetMap.set(p, 'globalContext');
          // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
          if (descriptor && !descriptor.configurable) {
            descriptor.configurable = true;
          }
          return descriptor;
        }

        return undefined;
      },

      // trap to support iterator with sandbox
      ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
        return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
      },

      defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
        const from = descriptorTargetMap.get(p);
        /*
         Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
         otherwise it would cause a TypeError with illegal invocation.
         */
        switch (from) {
          case 'globalContext':
            return Reflect.defineProperty(globalContext, p, attributes);
          default:
            return Reflect.defineProperty(target, p, attributes);
        }
      },

      deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
        this.registerRunningApp(name, proxy);
        if (target.hasOwnProperty(p)) {
          // @ts-ignore
          delete target[p];
          updatedValueSet.delete(p);

          return true;
        }

        return true;
      },

      // makes sure `window instanceof Window` returns truthy in micro app
      getPrototypeOf() {
        return Reflect.getPrototypeOf(globalContext);
      },
    });

    this.proxy = proxy;

    activeSandboxCount++;
  }
}

同樣我們把它分成幾塊來理解

啟用時

active() {
	// 記錄啟用沙箱的數量
  if (!this.sandboxRunning) activeSandboxCount++;
  this.sandboxRunning = true;
}

失活時

inactive() {
  ...
  if (--activeSandboxCount === 0) {
		// variableWhiteList記錄了白名單屬性,需要在沙箱全部失活時進行屬性的刪除
    variableWhiteList.forEach((p) => {
      if (this.proxy.hasOwnProperty(p)) {
        delete this.globalContext[p];
      }
    });
  }

  this.sandboxRunning = false;
}

Proxy代理

set部分

set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
    if (this.sandboxRunning) {
			// 記錄當前執行的微應用
      this.registerRunningApp(name, proxy);
      // 當前target不存在,但是globalContext中存在進行賦值
      if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
        const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
        const { writable, configurable, enumerable } = descriptor!;
        // 判斷是否可寫入,寫入target即fakeWindow中
				if (writable) {
          Object.defineProperty(target, p, {
            configurable,
            enumerable,
            writable,
            value,
          });
        }
      } else {
        target[p] = value;
      }

			// 如果在白名單中直接在全域性賦值
      if (variableWhiteList.indexOf(p) !== -1) {
        globalContext[p] = value;
      }
			// 變更記錄
      updatedValueSet.add(p); 
      this.latestSetProp = p;
      return true;
    }
    ...
    return true;
 },

get部分

get: (target: FakeWindow, p: PropertyKey): any => {
        this.registerRunningApp(name, proxy);
				// 防止逃逸,對不同情況進行處理
        if (p === Symbol.unscopables) return unscopables;
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        if (p === 'globalThis') {
          return proxy;
        }

        if (
          p === 'top' ||
          p === 'parent' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          if (globalContext === globalContext.parent) {
            return proxy;
          }
          return (globalContext as any)[p];
        }

        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

				// 直接返回document
        if (p === 'document') {
          return document;
        }
				
				//直接返回eval
        if (p === 'eval') {
          return eval;
        }
				// 參考https://github.com/umijs/qiankun/discussions/1411
        const value = propertiesWithGetter.has(p)
          ? (globalContext as any)[p]
          : p in target
          ? (target as any)[p]
          : (globalContext as any)[p];
        // 異常的處理,呼叫某些api的時候會出現呼叫異常的情況
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return getTargetValue(boundTarget, value);
      }

其他操作的一些相容性處理,進一步保證了沙箱的安全

has(target: FakeWindow, p: string | number | symbol): boolean {...},
getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {...},
ownKeys(target: FakeWindow): ArrayLike<string | symbol> {...},
defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {...},
deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {...},
getPrototypeOf() {...}

該模式最具優勢的一點是操作基於window上複製的副本FakeWindow,從而保證了多個沙箱例項並行的情況。


最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star

相關文章