有哪些動態執行腳本的場景?
在一些應用中, 我們希望給使用者提供插入自訂邏輯的能力, 比如 Microsoft 的 Office 中的 VBA, 比如一些遊戲中的 lua腳本,
大多數都是一些用戶端程式, 在一些線上的系統和產品中也常常也有類似的需求, 事實上, 線上的應用中也有不少提供了自訂腳本的能力, 比如 Google Docs 中的 Apps Script, 它可以讓你使用 JavaScript做一些非常有用的事情, 比如運行代碼來回應文檔打開事件或儲存格更改事件, 為公式製作自訂試算表函數等等。
與運行在「用戶電腦中」的用戶端應用不同, 用戶的自訂腳本通常只能影響用戶自已, 而對於線上的應用或服務來講, 有一些情況就變得更為重要, 比如「安全」, 用戶的「自訂腳本」必須嚴格受到限制和隔離, 即不能影響到宿主程序,
而 Safeify 就是一個針對 Nodejs 應用, 用於安全執行用戶自訂的非信任腳本的模組。
怎樣安全的執行動態腳本?我們先看看通常都能如何在 JavaScript 程式中動態執行一段代碼?比如大名頂頂的 eval
eval('1+2')上述代碼沒有問題順利執行了, eval是全域物件的一個函數屬性, 執行的代碼擁有著和應程中其它正常代碼一樣的的許可權, 它能訪問「執行上下文」中的區域變數, 也能訪問所有「全域變數」, 在這個場景下, 它是一個非常危險的函數。
再來看看 Functon, 通過 Function構造器, 我們可以動態的創建一個函數, 然後執行它
const sum = new Function('m', 'n', 'return m + n');console.log(sum(1, 2));它也一樣的順利執行了, 使用 Function 構造器生成的函數, 並不會在創建它的上下文中創建閉包, 一般在全域作用域中被創建。
我們知道無論 eval還是 function,
在流覽器中, 還可以利用 iframe, 創建一個再發安全的一些隔離環境, 本文也著眼於 Node.js, 在這裡不做過多討論。
在 Node.js 中呢, 有沒有其它選擇?或許沒看到這兒之前你就已經想到了 VM, 它是 Node.js 預設就提供的一個內建模組, VM模組提供了一系列 API 用於在 V8 虛擬機器環境中編譯和運行代碼。 JavaScript 代碼可以被編譯並立即運行, 或編譯、保存然後再運行。
執行上這的代碼就能拿到結果 3, 同時, 通過 vm.Script還能指定代碼執行了「最大毫秒數」, 超過指定的時長將終止執行並拋出一個異常
try { const script = new vm.Script('while(true){}',{ timeout: 50 }); ....} catch (err){ //列印超時的 log console.log(err.message);}上面的腳本執行將會失敗,
上述代碼, 並不是會在 50ms 後拋出異常, 因為 50ms 上邊的代碼同步執行肯定完了, 而 setTimeout所用的時間並不算在內, 也就是說 vm模組沒有辦法對非同步代碼直接限制執行時間。 我們也不能額外通過一個 timer去檢查超時, 因為檢查了執行中的 vm 也沒有方法去中止掉。
另外, 在 Node.js 通過 vm.runInContext看起來似乎隔離了代碼執行環境, 但實際上卻很容易「逃逸」出去。
執行上邊的代碼,
宿主程序立即就會「退出」,
sandbox是在 VM之外的環境創建的,
需 VM中的代碼的 this
指向的也是 sandbox,
那麼
沒有人願意用戶一段腳本就能讓應用掛掉吧。 除了退出進程式之外, 實際上還能幹更多的事情。
有個簡單的方法就能避免通過 this.constructor拿到 process,如下:
但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文檔中也提到 VM當做一個安全的沙箱去執行任意非信任的代碼。
有哪些做了進一步工作的社區模組?
在社區中有一些開源的模組用於運行不信任代碼,例如 sandbox、vm2、jailed等。相比較而言 vm2對各方面做了更多的安全工作,相對安全些。
從 vm2的官方 READM中可以看到,它基於 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了。從另一個層面來看,況且或許有時我們還想讓自訂腳本支援非同步處理呢。
如何建立一個更安全一些的沙箱?通過上文的探究,我們並沒有找到一個完美的方案在 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 通訊。
最終,我們建立了一個大約這樣的「沙箱環境」如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝為一個獨立的模組 Safeify。
GitHub: https://github.com/Houfeng/safeify ,歡迎 Star & Issues
最後,簡單介紹一下 Safeify 如何使用,通過如下命令安裝
npm i safeify --save在應用中使用,還是比較簡單的,如下代碼(TypeScript 中類似)
import { Safeify } from './Safeify';const safeVm = new Safeify({ timeout: 50, //超時時間,默認 50ms asyncTimeout: 500, //包含非同步作業的超時時間,默認 500ms quantity: 4 //沙箱進程數量,預設同 CPU 核數});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 能對大家有所幫助就行了。
作者:houfeng
實際上還能幹更多的事情。有個簡單的方法就能避免通過 this.constructor拿到 process,如下:
但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文檔中也提到 VM當做一個安全的沙箱去執行任意非信任的代碼。
有哪些做了進一步工作的社區模組?
在社區中有一些開源的模組用於運行不信任代碼,例如 sandbox、vm2、jailed等。相比較而言 vm2對各方面做了更多的安全工作,相對安全些。
從 vm2的官方 READM中可以看到,它基於 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了。從另一個層面來看,況且或許有時我們還想讓自訂腳本支援非同步處理呢。
如何建立一個更安全一些的沙箱?通過上文的探究,我們並沒有找到一個完美的方案在 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 通訊。
最終,我們建立了一個大約這樣的「沙箱環境」如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝為一個獨立的模組 Safeify。
GitHub: https://github.com/Houfeng/safeify ,歡迎 Star & Issues
最後,簡單介紹一下 Safeify 如何使用,通過如下命令安裝
npm i safeify --save在應用中使用,還是比較簡單的,如下代碼(TypeScript 中類似)
import { Safeify } from './Safeify';const safeVm = new Safeify({ timeout: 50, //超時時間,默認 50ms asyncTimeout: 500, //包含非同步作業的超時時間,默認 500ms quantity: 4 //沙箱進程數量,預設同 CPU 核數});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 能對大家有所幫助就行了。
作者:houfeng