如何編寫一個前端框架之三-程式碼執行沙箱(譯)

tristan發表於2018-04-17

本系列一共七章,Github 地址請查閱這裡,原文地址請查閱這裡

沙箱中程式碼求值

這是編寫一個前端框架系列的第三章,本章我將會闡述瀏覽器端不同的程式碼求值的方法及其所產生的問題。我也將會介紹一個方法,它依賴於一些新穎或者少見的 JavaScript 功能。

邪惡的 eval

eval() 函式用於對字串形式的 JavaScript 程式碼進行求值。

程式碼求值的最常見的解決方案即使用 eval() 函式。由 eval() 執行的程式碼能夠訪問閉包和全域性作用域,這會導致被稱為程式碼注入 code injection 的安全隱患,正因此讓 eval() 成為 JavaScript 最臭名昭著的功能之一。

雖然讓人不爽,但是在某些情況下 eval() 是非常有用的。大多數的現代框架需要它的功能,但是因為上面提到的問題而不敢使用。結果,出現了許多在沙箱而非全域性作用域中的字串求值的替代方案。沙箱防止程式碼訪問安全資料。一般情況下,它是一個簡單的物件,這個物件會為求值程式碼替換掉全域性的物件。

常規方案

替代 eval() 最常見的方式即為完全重寫 - 分兩步走,包括解析和解釋字串。首先解析器建立一個抽象語法樹(AST),然後直譯器遍歷語法樹並在沙箱中解釋為程式碼。

這是被最為廣泛使用的方案,但是對於如此簡單的事情被認為是牛刀小用。從零開始重寫所有的東西而不是為 eval() 打補丁會導致易出很多的 bug, 並且它還要求頻繁地修改以匹配語言的升級更新。

替代方案

NX 試圖避免重新實現原生程式碼。程式碼求值是由一個使用了一些新或者冷門的 JavaScript 功能的小型庫來處理的。

本節將會循序漸進地介紹這些功能,然後由它們來介紹 nx-compile 是如何執行程式碼的。此庫含有一個被稱為 compileCode() 的庫,執行方式類似以下程式碼:

const code = compileCode('return num1 + num2')
// this logs 17 to the console
console.log(code({num1: 10, num2: 7}))

const globalNum = 12
const otherCode = compileCode('return globalNum')

// global scope access is prevented
// this logs undefined to the console
console.log(otherCode({num1: 2, num2: 3}))
複製程式碼

在本章末尾,我們將會以少於 20 行的程式碼來實現 compileCode 函式。

new Function()

函式構建器建立了一個新的函式物件。在 JavaScript 中,每個函式都實際上是一個函式物件。

Function 構造器是 eval() 的一個替代方案。new Function(...args, 'funcBody') 對傳入的 'funcBody' 字串進行求值,並返回執行這段程式碼的函式。它和 eval() 主要有兩點區別:

  • 它只會對傳入的程式碼求值一次。呼叫返回的函式會直接執行程式碼,而不會重新求值。
  • 它不能訪問本地閉包變數,但是仍然可以訪問全域性作用域。
function compileCode(src) {
	return new Function(src)
}
複製程式碼

new Function() 在我們的需求中是一個更好的替代 eval() 的方案。它有很好的效能和安全性,但是為使其可行需要遮蔽其對全域性作用域的訪問。

With 關鍵字

with 宣告為一個宣告語句擴充了作用域鏈

with 是 JavaScript 一個冷門的關鍵字。它允許一個半沙箱的執行環境。with 程式碼塊中的程式碼會首先試圖從傳入的沙箱物件獲得變數,但是如果沒找到,則會在閉包和全域性作用域中尋找。閉包作用域的訪問可以用 new Function() 來避免,所以我們只需要處理全域性作用域。

function compileCode(src) {
  src = 'with (sandbox) {' + src + '}'
  return new Function('sandbox', src)
}
複製程式碼

with 內部使用 in 運算子。在塊中訪問每個變數,都會使用variable in sandbox 條件進行判斷。若條件為真,則從沙箱物件中讀取變數。否則,它會在全域性作用域中尋找變數。通過欺騙 with 可以讓variable in sandbox 一直返回真,我們可以防止它訪問全域性作用域。

如何編寫一個前端框架之三-程式碼執行沙箱(譯)

ES6 代理

代理物件用於定義基本操作的自定義行為,如屬性查詢或賦值。

一個 ES6 proxy 封裝一個物件並定義陷阱函式,這些函式可以攔截對該物件的基本操作。當操作發生的時候,陷阱函式會被呼叫。通過在Proxy 中包裝沙箱物件並定義一個 has 陷阱,我們可以重寫 in 運算子的預設行為。

function compileCode(src) {
  src ='with (sandbox) {' + src + '}
  const code = new Function('sandbox', src)
  
  return function(sandbox) {
    const sandboxProxy = new Proxy(sandbox, {has})
    return code(sandboxProxy)
  }
}

// this trap intercepts 'in' operations on sandboxProxy
function has(target, key) {
  return true
}
複製程式碼

以上程式碼欺騙了 with 程式碼塊。variable in sandbox 求值將會一直是 true 值,因為 has 陷阱函式會一直返回 true。with 程式碼塊將永遠都不會嘗試訪問全域性物件。

如何編寫一個前端框架之三-程式碼執行沙箱(譯)

Symbol.unscopables

標記是一個唯一和不可變的資料型別,可以被用作物件屬性的一個識別符號。

Symbol.unscopables 是一個著名的標記。一個著名的標記即是一個內建的 JavaScript Symbol,它可以用來代表內部語言行為。例如,著名的標記可以被用作新增或者覆寫遍歷或者基本型別轉換。

Symbol.unscopables 著名標記用來指定一個物件自身和繼承的屬性的值,這些屬性被排除在 with 所繫結的環境之外。

Symbol.unscopables 定義了一個物件的 unscopable(不可限定)屬性。在with語句中,不能從Sandbox物件中檢索Unscopable屬性,而是直接從閉包或全域性作用域檢索屬性。Symbol.unscopables 是一個不常用的功能。你可以在本頁上閱讀它被引入的原因。

如何編寫一個前端框架之三-程式碼執行沙箱(譯)

我們可以通過在沙箱的 Proxy 屬性中定義一個 get 陷阱來解決以上的問題,這可以攔截 Symbol.unscopables 檢索,並且一直返回未定義。這將會欺騙 with 塊的程式碼認為我們的沙箱物件沒有 unscopable 屬性。

function compileCode(src) {
  src = 'with(sandbox) {' + src + '}'
  const code = new Function('sandbox', src)
  
  return function(sandbox) {
    const sandboxProxy = new Proxy(sandbox, {has, get})
    return code(sandboxProxy)
  }
}

function has(target, key) {
  return true
}
  
function get(target, key) {
  if (key === Symbol.unscopables) return undefined
  return target[key]
}
複製程式碼

如何編寫一個前端框架之三-程式碼執行沙箱(譯)

使用 WeakMaps 來做快取

現在程式碼是安全的,但是它的效能仍然可以升級,因為它每次呼叫返回函式時都會建立一個新的代理。可以使用快取來避免,每次呼叫時,若沙箱物件相同,則可以使用同一個 Proxy 物件。

一個代理屬於一個沙箱物件,所以我們可以簡單地把代理新增到沙箱物件中作為一個屬性。然而,這將會對外暴露我們的實現細節,並且如果不可變的沙箱物件被 Object.freeze() 函式凍結了,這就行不通了。在這種情況下,使用 WeakMap 是一個更好的替代方案。

WeakMap 物件是一個鍵/值對的集合,其中鍵是弱引用。鍵必須是物件,而值可以是任意值。

一個 WeakMap 可以用來為物件新增資料,而不用直接用屬性來擴充套件資料。我們可以使用 WeakMaps 來間接地為沙箱物件新增快取代理。

const sandboxProxies = new WeakMap()

function compileCode (src) {
	src = 'with (sandbox) {' + src + '}'
	const code = new Function('sandbox', src)
	
	return function(sandbox) {
		if (!sandboxProxies.has(sandbox)) {
      const sandboxProxy = new Proxy(sandbox, {has, get})
      sandboxProxies.set(sandbox, sandboxProxy)
		}
		return code(sandboxProxies.get(sandbox))
	}
}

function has(target, key) {
  return true
}

function get(target, key) {
  if (key === Symbol.unscopables) return undefined
  return target[key]
}
複製程式碼

這樣,每個沙箱物件只能建立一個Proxy

最後說明

以上的 compileCode 例子是一個只有 19 行程式碼的可用的沙箱程式碼評估器。如果你想要看 nx-compile 庫的完整原始碼,可以參見這裡

除了解釋程式碼求值,本章的目標是為了展示如何利用新的 ES6 功能來改變現有的功能,而不是重新發明它們。我試圖通過這些例子來展示 ProxiesSymbols 的所有功能。

Github 地址請查閱這裡,原文地址請查閱這裡,接下來講解的是資料繫結簡介。

相關文章