(接上文)
在主線程上運行的含義
在我們深入進行第二種嘗試之前,我們需要先退一步,並重新考察允許插件在主線程上運行到底意味著什麼。畢竟,我們一開始並沒有考慮它,因為我們知道這可能是危險的。在主線程上運行聽起來很像eval(UNSAFE_CODE)方式。
在主線程上運行的好處是插件可以:
1.直接編輯文檔而不是副本,避免加載時間問題。
2.可以運行複雜的組件更新和約束邏輯,而無需為代碼置辦兩個副本。
3.在需要同步API時,可以使用同步API調用。這樣的話,更新的加載或刷新就不會發生混淆。
4.以更直觀的方式編寫代碼:插件只是自動執行用戶可以使用UI手動執行的操作。
但是,這時我們又遇到了下列問題:
1.插件可掛起,但無法中斷插件。
2.插件可以像figma.com一樣發出網絡請求。
3.插件可以訪問和修改全局狀態,例如修改UI,甚至可以執行惡意操作,例如修改({}).__proto__的值,從而危害所有新建的和現有的JavaScript對象。
經過斟酌之後,我們決定放棄第1項要求。當插件被凍結時,會影響Figma的穩定性。然而,我們的插件模型的工作原理是,它們只處理顯式的用戶操作。通過在插件運行時更改UI,凍結將始終被認為是插件所致。這也意味著插件無法「破壞」文檔。
eval的危險性體現在哪些方面?
為了解決插件能夠發出網絡請求和訪問全局狀態的問題,我們必須首先確切地了解「通過eval函數執行任意JavaScript代碼是危險的」這句話到底意味著什麼。
對於某些只能進行7*24*60*60這樣的算術運算的JavaScript變體,我們稱之為SimpleScript,那麼使用eval方法的話還是很安全的。
如果繼續為SimpleScript添加其他特性,如變量賦值和if語句,使其更像程式語言,這時它仍然非常安全。歸根結底,它本質上仍然歸結為做算術。如果繼續添加函數求值(function evaluation)特性,現在該語言就具備了λ演算和圖靈完備性。
換句話說,JavaScript未必一定就是危險的。在最簡化的形式中,它只是一種做算術的擴展方式。真正的危險源是它的輸入和輸出訪問權限,其中包括網絡訪問、DOM訪問等,即危險的是瀏覽器的應用程式接口。
我們知道,API都是全局變量,因此,我們需要隱藏全局變量!
隱藏全局變量
現在,隱藏全局變量在理論上聽起來不錯,但僅通過「隱藏」它們來創建安全的實現還是很困難的。例如,我們可以考慮刪除window對象的所有屬性,或將它們設置為null,但代碼仍然可以訪問全局值,例如({}).constructor。所以,找出洩漏全局變量值得所有可能方式是非常具有挑戰性的。
相反,我們需要一些更強大的沙箱形式,使得這些全局變量值從一開始就不存在。
換句話說,JavaScript並不一定非常危險。
考慮前面介紹的僅支持算術的SimpleScript語言,大家可以試著編寫一個算術運算程序。在該程序的任何合理實現中,SimpleScript將無法執行除算術之外的任何操作。
現在,我們繼續擴展SimpleScript,使其支持更多語言功能,直到它變成JavaScript為止,現在,我們將該程序稱為解釋器,它決定了JavaScript(動態解釋語言)的運行方式。
嘗試#2:將JavaScript解釋器編譯為WebAssembly
對於像我們這樣的小型創業公司來說,實現JavaScript編譯器是不太現實的。相反,為了驗證這種方法,我們採用了Duktape,這是一個用C++編寫的輕量級JavaScript解釋器,並將其編譯為WebAssembly。
為了確認它是否有效,我們運行了test262測試,它是標準的JavaScript測試套件。它通過了所有ES5測試,只有少量不重要的測試失敗了。要使用Duktape運行插件代碼,我們需要使用編譯為WebAssembly的解釋器來調用eval函數。
這種方法有哪些特性?
這個解釋器在主線程中運行,這意味著我們可以創建一個基於主線程的API。
它是安全的,因為Duktape不支持任何瀏覽器API,此外,它是作為WebAssembly運行的,而後者是一個無法訪問瀏覽器API的沙箱環境。換句話說,默認情況下,插件代碼只能通過顯式的白名單API與外界進行通信。
它比常規JavaScript的速度要慢,因為這個解釋器不支持JIT,但這並不重要。
它需要瀏覽器編譯一個中等大小的WASM二進位文件,這需要一些開銷。
默認情況下,瀏覽器調試工具無法使用,但我們花了一天時間為解釋器實現了一個控制臺,以驗證它至少可以調試插件。
Duktape僅支持ES5,但在Web社區中,通常會使用[Babel](https://babeljs.io/)等工具交叉編譯較新的JavaScript版本。
(提示:幾個月後,Fabrice Bellard發布了[QuickJS](https://bellard.org/quickjs/),它原生支持ES6。)
現在,我們要編譯一個JavaScript解釋器!根據你作為程式設計師的愛好或審美傾向,您可能會想:
這太棒了!
或者
……這是要搞啥?還要自己搞JavaScript引擎,那作業系統是不是也要自己搞一個呀?
當然,這些質疑聲是非常正常的!除非我們有絕對的必要,否則最好避免重新實現瀏覽器。在實現整個渲染系統方面,我們花費的大量的精力,因為這對於性能和跨瀏覽器支持來說是非常必要的,並且令人高興的是,我們的確做到了,但我們仍然要鄭重對讀者說一聲:不重新發明輪子。
注意,這並非我們最終採用的方法,因為後面還有更好的方法。那我們為什麼要在這裡介紹它呢?這是因為,這對於理解我們最終沙箱模型來說是非常有幫助的,畢竟我們的模型是非常複雜的。
嘗試#3:Realms
雖然編譯JS解釋器是一種很有前途的方法,但除此之外,還有一個方法非常需要考慮——Realms shim技術,其創建者為Agoric。
這項技術將創建沙箱和支持插件描述為潛在的用例。這真是一種前途無量的描述方法!Realms API看起來大致如下所示:
let g = window; // outer global
let r = new Realm(); // realm object
let f = r.evaluate("(function() { return 17 })");
f() === 17 // true
Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true
這種技術實際上可以使用現有的JavaScript特性來實現,儘管這些特性鮮為人知。沙箱的一項任務就是隱藏全局變量。這個shim庫的核心功能大致如下所示:
function simplifiedEval(scopeProxy, userCode) {
'use strict'
with (scopeProxy) {
eval(userCode)
}
}
這是用於演示目的的簡化版本;真實版本中還是有一些細微差別的。但是,它展示了其中最關鍵的部分:with語句和Proxy對象。
其中,with(obj)語句創建了一個作用域,在該作用域內可以使用obj的屬性查找變量。在這個例子中,我們可以將變量PI、cos和sin解析為Math對象的屬性。另一方面,console並不是Math的屬性,因此需要在全局作用域內進行解析。
with (Math) {
a = PI * r * r
x = r * cos(PI)
y = r * sin(PI)
console.log(x, y)
}
代理對象是JavaScript對象最動態的一種形式。
· 最基本的JavaScript對象可以通過訪問obj.x返回屬性的值。
· 更高級的JavaScript對象可以具有getter屬性,用於返回函數的計算結果。實際上,訪問obj.x就是調用x的getter屬性。
· 代理可以通過運行函數get來訪問任意屬性。
對於下面的代理(由於它僅用於演示,所以進行了相應的簡化處理)來說,當我們嘗試訪問它的任何屬性時,都將返回undefined,而不是對象whitelist中的屬性值。
const scopeProxy = new Proxy(whitelist, {
get(target, prop) {
// here, target === whitelist
if (prop in target) {
return target[prop]
}
return undefined
}
}
現在,當您將這個代理用作with對象的參數時,它將攔截所有變量的解析過程,並且永遠不會使用全局作用域來解析變量:
with (proxy) {
document // undefined!
eval("xhr") // undefined!
}
不過,這種方法仍然可以通過諸如({}).constructor之類的表達式來訪問某些全局變量。此外,沙箱也確實需要訪問一些全局變量。例如,Object是一個全局對象,並且許多合法的JavaScript代碼(例如Object.keys)都需要用到它。
為了讓插件既能夠訪問這些全局變量又不會捅婁子,Realms沙箱支持通過創建同源的iframe來實例化所有這些全局變量的新副本。當然,這個iframe不會像在嘗試#1中那樣用作沙箱。並且,同源iframe不會受CORS的限制。
相反,當在與父文檔同源的情況下創建<inline-iframe>時:
1.它附帶了所有全局變量的單獨副本,例如Object.prototype等。
2.可以從父文檔訪問這些全局變量。
這些全局變量將被放進代理對象的「白名單」中,這樣的話,插件就可以訪問它們了。最後,這個新的<inline-iframe>還附帶了一個新的「eval」函數副本,它與現有的函數有一個重要的區別:即使只有通過({}).constructor這樣的語法才能訪問的內置值,也將會解析為iframe的副本。
這種基於Realms的沙箱方法有許多優秀的屬性:
它在主線程上運行。
速度很快,因為它可以使用瀏覽器的JavaScript JIT來執行代碼。
瀏覽器開發工具仍可以正常使用。
即使如此,我們還面臨令一個非常重要的問題:這種方法安全嗎?
(未完待續)