【干貨】如何安全的運行第三方JavaScript代碼(下)?
使用Realms安全地實現API
總的來說,我們覺得Realms的沙箱功能還是非常不錯。盡管與JavaScript解釋器方法相比,我們要處理更多細節,但它仍然可以作為白名單而不是黑名單來運作,這使其實現代碼更加緊湊,因此也更便于審計。作為另一個加分項,它還是由受人尊敬的Web社區成員所創建的。
但是,單靠Realms仍然無法滿足我們的要求,因為它只是一個沙箱,插件在其中不能做任何事情。我們仍然需要實現可以供插件使用的API。這些API也必須是安全的,因為大多數插件都需要能夠顯示一些UI,以及發送網絡請求。
例如,假設沙箱默認情況下不包含console對象。畢竟,console是一個瀏覽器API,而不是JavaScript功能。為此,我們可以將其作為全局變量傳遞給沙箱。
realm.evaluate(USER_CODE, { log: console.log })
或者將原來的值隱藏在函數中,使沙箱無法修改它們:
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
不幸的是,這是一個安全漏洞。即使在第二個示例中,匿名函數也是在realm之外創建的,卻直接提供給了realm。這意味著插件可以通過log函數的原型鏈逃逸到沙箱之外。
實現console.log的正確方法是將其封裝到在realm內部創建的函數中。這里是一個簡化版本的示例(實際上,它還需要對realms間拋出異常進行相應的轉換處理)。
// Create a factory function in the target realm.
// The factory return a new function holding a closure.
const safeLogFactory = realm.evaluate(
(function safeLogFactory(unsafeLog) {
return function safeLog(...args) {
unsafeLog(...args);
}
})
);
// Create a safe function
const safeLog = safeLogFactory(console.log);
// Test it, abort if unsafe
const outerIntrinsics = safeLog instanceof Function;
const innerIntrinsics = realm.evaluate(log instanceof Function, { log: safeLog });
if (outerIntrinsics || !innerIntrinsics) throw new TypeError();
// Use it
realm.evaluate(log("Hello outside world!"), { log: safeLog });
這就帶來一個問題——雖然該方法能用于構建一個安全的應用程序接口,但是開發人員每次向應用程序接口添加一個新函數時,都需要考察對象源在語義上是否有問題。那我們該怎么解決呢?
用于解釋器的API
問題是直接利用Realms構建Figma應用編程接口的話,則需要對每個API端點都進行安全審計,包括其輸入和輸出值。很明顯,這樣的話,工作量實在太大了。
盡管Realms沙箱中的代碼是使用相同的JavaScript引擎運行的,但如果假設我們仍然面臨WebAssembly方法所帶來的限制的話,這對我們是非常有幫助的。
回顧一下Duktape,在嘗試#2章節中,JavaScript解釋器將被編譯為WebAssembly。因此,主線程中的JavaScript代碼無法直接保存對沙箱內對象的引用。畢竟,在沙箱中,WebAssembly是通過自己來管理堆的,因此,所有JavaScript對象都位于這個堆所在的內存空間中。事實上,Duktape甚至可能沒有使用與瀏覽器引擎相同的內存表示來實現JavaScript對象!
因此,Duktape的API只能借助于低級操作實現,例如一會兒將整數和字符串復制到虛擬機中,一會兒再復制回來。即便可以在解釋器中保存對象或函數的引用,但也僅能作為不透明句柄使用。
這種接口看起來像下面這樣:
// vm == virtual machine == interpreter
export interface LowLevelJavascriptVm {
typeof(handle: VmHandle): string
getNumber(handle: VmHandle): number
getString(handle: VmHandle): string
newNumber(value: number): VmHandle
newString(value: string): VmHandle
newObject(prototype?: VmHandle): VmHandle
newFunction(name: string, value: (this: VmHandle, ...args: VmHandle[]) => VmHandle): VmHandle
// For accessing properties of objects
getProp(handle: VmHandle, key: string | VmHandle): VmHandle
setProp(handle: VmHandle, key: string | VmHandle, value: VmHandle): void
defineProp(handle: VmHandle, key: string | VmHandle, descriptor: VmPropertyDescriptor): void
callFunction(func: VmHandle, thisVal: VmHandle, ...args: VmHandle[]): VmCallResult
evalCode(code: string): VmCallResult }
export interface
VmPropertyDescriptor {
configurable?: boolean enumerable?: boolean
get?: (this: VmHandle) => VmHandle
set?: (this: VmHandle, value: VmHandle) => void }
請注意,這些就是API實現將要使用的接口,但它或多或少地以一對一的形式映射到Duktape的解釋器API。畢竟,Duktape(和類似的虛擬機)的構建正是為了以嵌入形式使用,并允許嵌入方與Duktape進行通信。
使用該接口,可以將對象{x: 10, y: 10}傳遞到沙箱,具體如下所示:
let vm: LowLevelJavascriptVm = createVm()
let jsVector = { x: 10, y: 10 }
let vmVector = vm.createObject()
vm.setProp(vmVector, "x", vm.newNumber(jsVector.x))
vm.setProp(vmVector, "y", vm.newNumber(jsVector.y))
下面給出用于Figma節點對象的“opacity”屬性的API:
vm.defineProp(vmNodePrototype, 'opacity', {
enumerable: true,
get: function(this: VmHandle) {
return vm.newNumber(getNode(vm, this).opacity) },
set: function(this: VmHandle, val: VmHandle) {
getNode(vm, this).opacity = vm.getNumber(val)
return vm.undefined } })
這個底層接口可以通過Realms沙箱很好地實現。這樣的實現只需要相對較少的代碼(就本例來說,大約為500 LOC)。不過,我們需要對這一小部分代碼進行仔細審計。但是,一旦完成了上述工作,就可以直接利用這些接口來開發其他的API,而不用擔心沙箱方面的安全問題。
從本質上講,這就是將JavaScript解釋器和Realms沙箱視為“運行JavaScript代碼的一些獨立環境”。
在沙箱上創建低級抽象還需要關注另一個關鍵問題。雖然我們對Realms的安全性充滿了信心,但根據經驗,在安全方面再小心也不為過。所以,我們不妨假設Realms中存在未知的安全漏洞,總有一天會變成我們必須處理的問題。
這就是前面花了許多章節來介紹如何編譯一個甚至不用的解釋器的原因。因為該API是通過一個其實現可以互換的接口實現的,所以,解釋器仍然是一個有效的備份計劃,我們可以在無需重新實現任何API或破壞任何現有插件的情況下啟用它。
插件功能的多樣性
現在,我們獲得了可以安全運行任意插件的沙箱,以及允許這些插件操作Figma文檔的API。這就相當于為我們的世界打開了一扇大門。
但是,我們試圖解決的最初問題是為設計工具構建插件系統。為提高可用性,這些插件中的大部分都需要具備創建用戶界面的功能,并且許多插件還需要具有某種形式的網絡訪問能力。更一般地說,我們希望插件能夠盡可能多地利用瀏覽器和JavaScript的生態系統。
我們可以一次一個地、小心謹慎地公開安全的、受限制的瀏覽器API版本,就像上面的console.log示例一樣。然而,瀏覽器API(尤其是DOM)的涉及面太大,甚至比JavaScript本身還要大。這種嘗試可能因限制太多而無法使用,或者可能存在安全缺陷。
我們通過重新引入源為null的來解決這個問題。這樣的話,插件就可以創建一個并在其中放置任意HTML和Javascript代碼了。
這跟我們最初嘗試使用的的區別在于,現在,插件是由兩個組件組成:
· 一個可以訪問Figma文檔并在Realms沙箱內的主線程上運行的組件。
· 一個可以訪問瀏覽器API并在內部運行的組件。
這兩個組件可以通過消息傳遞進行通信。雖然這種架構使得使用瀏覽器API比在同一環境中運行這兩個組件要繁瑣一些,但是,鑒于目前的瀏覽器技術的狀況,這是安全地運行他人Javascript代碼的最佳技術,當然,隨著技術的進步,將來一定會出現更好的插件創建技術。
小結
經過一段曲折的探索之旅后,我們終于找到了一個實現插件的行之有效的解決方案。借助于Realm的shim庫,我們不僅實現了第三方代碼的隔離,同時仍然允許它在開發人員熟悉的類瀏覽器環境中運行。