為Node.js 應用建立一個更安全的沙箱環境
有哪些動態執行腳本的場景?
在一些應用中,我們希望給用戶提供插入自定義邏輯的能力,比如Microsoft的Office中的VBA
,比如一些遊戲中的lua
腳本,FireFox的「油猴腳本」,能夠讓用戶發在可控的範圍和權限內發揮想像做一些好玩、有用的事情,擴展了能力,滿足用戶的個性化需求。
大多數都是一些客戶端程序,在一些在線的系統和產品中也常常也有類似的需求,事實上,在線的應用中也有不少提供了自定義腳本的能力,比如Google Docs中的Apps Script
,它可以讓你使用JavaScript
做一些非常有用的事情,比如運行代碼來響應文檔打開事件或單元格更改事件,為公式製作自定義電子表格函數等等。
與運行在「用戶電腦中」的客戶端應用不同,用戶的自定義腳本通常只能影響用戶自已,而對於在線的應用或服務來講,有一些情況就變得更為重要,比如「安全」 ,用戶的「自定義腳本」必須嚴格受到限制和隔離,即不能影響到宿主程序,也不能影響到其它用戶。
而Safeify 就是一個針對Nodejs 應用,用於安全執行用戶自定義的非信任腳本的模塊。
怎樣安全的執行動態腳本?
我們先看看通常都能如何在JavaScript 程序中動態執行一段代碼?比如大名頂頂的eval
eval('1+2')
上述代碼沒有問題順利執行了, eval
是全局對象的一個函數屬性,執行的代碼擁有著和應用中其它正常代碼一樣的的權限,它能訪問「執行上下文」中的局部變量,也能訪問所有「全局變量」,在這個場景下,它是一個非常危險的函數。
再來看看Functon
,通過Function
構造器,我們可以動態的創建一個函數,然後執行它
constsum=newFunction('m','n','return m + n');console.log(sum(1,2));
它也一樣的順利執行了,使用Function 構造器生成的函數,並不會在創建它的上下文中創建閉包,一般在全局作用域中被創建。當運行函數的時候,只能訪問自己的本地變量和全局變量,不能訪問Function 構造器被調用生成的上下文的作用域。如同一個站在地上、一個站在一張薄薄的紙上一樣,在這個場景下,幾乎沒有高下之分。
結合ES6的新特性Proxy
便能更安全一些
functionevalute(code,sandbox){sandbox=sandbox||Object.create(null);constfn=newFunction('sandbox',`with(sandbox){return (${code})}`);constproxy=newProxy(sandbox,{has(target,key){//讓動態執行的代碼認為屬性已存在returntrue;}});returnfn(proxy);}evalute('1+2')// 3evalute('console.log(1)')// Cannot read property 'log' of undefined
我們知道無論eval
還是function
,執行時都會把作用域一層一層向上查找,如果找不到會一直到global
,那麼利用Proxy
的原理就是,讓執行了代碼在sandobx
中找的到,以達到「防逃逸」的目的。
在瀏覽器中,還可以利用iframe,創建一個再多安全一些的隔離環境,本文著眼於Node.js,在這裡不做過多討論。
在Node.js中呢,有沒有其它選擇?
或許沒看到這兒之前你就已經想到了VM
,它是Node.js默認就提供的一個內建模塊, VM
模塊提供了一系列API用於在V8虛擬機環境中編譯和運行代碼。 JavaScript 代碼可以被編譯並立即運行,或編譯、保存然後再運行。
constvm=require('vm');constscript=newvm.Script('m + n');constsandbox={m:1,n:2};constcontext=newvm.createContext(sandbox);script.runInContext(context);
執行上這的代碼就能拿到結果3
,同時,通過vm.Script
還能指定代碼執行了「最大毫秒數」,超過指定的時長將終止執行並拋出一個異常
try { const script = new vm . Script ( 'while(true){}' ,{ timeout : 50 }); .... } catch ( err ){ //打印超时的log console . log ( err . message ); }
上面的腳本執行將會失敗,被檢測到超時並拋出異常,然後被Try Cache
捕獲到並打出log,但同時需要注意的是vm.Script
的timeout
選項「只針對同步代有效」,而不包括是異步調用的時間,比如
constscript=newvm.Script('setTimeout(()=>{},2000)',{timeout:50});....
上述代碼,並不是會在50ms後拋出異常,因為50ms上邊的代碼同步執行肯定完了,而setTimeout
所用的時間並不算在內,也就是說vm
模塊沒有辦法對異步代碼直接限制執行時間。我們也不能額外通過一個timer
去檢查超時,因為檢查了執行中的vm也沒有方法去中止掉。
另外,在Node.js通過vm.runInContext
看起來似乎隔離了代碼執行環境,但實際上卻很容易「逃逸」出去。
constvm=require('vm');constsandbox={};constscript=newvm.Script('this.constructor.constructor("return process")().exit()');constcontext=vm.createContext(sandbox);script.runInContext(context);
執行上邊的代碼,宿主程序立即就會「退出」, sandbox
是在VM
之外的環境創建的,需VM
中的代碼的this
指向的也是sandbox
,那麼
//this.constructor 就是外部的Object const ObjConstructor = this . constructor ; //ObjConstructor 的constructor 就是外部的Function const Function = ObjConstructor . constructor ; //创建一个函数,并执行它,返回全局process 对象const process = ( new Function ( 'return process' ))(); //退出当前进程process . exit ();
沒有人願意用戶一段腳本就能讓應用掛掉吧。除了退出進程序之外,實際上還能幹更多的事情。
有個簡單的方法就能避免通過this.constructor
拿到process
,如下:
const vm = require ( 'vm' ); //创建一外无proto 的空白对象作为sandbox const sandbox = Object . create ( null ); const script = new vm . Script ( '...' ); const context = vm . createContext ( sandbox ); script . runInContext ( context );
但還是有風險的,由於JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實Node.js 的官方文檔中也提到「不要把VM 當做一個安全的沙箱,去執行任意非信任的代碼」。
有哪些做了進一步工作的社區模塊?
在社區中有一些開源的模塊用於運行不信任代碼,例如sandbox
、 vm2
、 jailed
等。相比較而言vm2
對各方面做了更多的安全工作,相對安全些。
從vm2
的官方READM
中可以看到,它基於Node.js內建的VM模塊,來建立基礎的沙箱環境,然後同時使用上了文介紹過的ES6的Proxy
技術來防止沙箱腳本逃逸。
用同樣的測試代碼來試試vm2
const{VM}=require('vm2');newVM().run('this.constructor.constructor("return process")().exit()');
如上代碼,並沒有成功結束掉宿主程序,vm2 官方REAME 中說「vm2 是一個沙盒,可以在Node.js 中按全的執行不受信任的代碼」。
然而,事實上我們還是可以乾一些「壞」事情,比如:
const{VM}=require('vm2');constvm=newVM({timeout:1000,sandbox:{}});vm.run('new Promise(()=>{})');
上邊的代碼將永遠不會執行結束,如同Node.js內建模塊一樣vm2的timeout
對異步操作是無效的。同時, vm2
也不能額外通過一個timer
去檢查超時,因為它也沒有辦法將執行中的vm終止掉。這會一點點耗費完服務器的資源,讓你的應用掛掉。
那麼或許你會想,我們能不能在上邊的sandbox
中放一個假的Promise
從而禁掉Promise呢?答案是能提供一個「假」的Promise
,但卻沒有辦法完成禁掉Promise
,比如
const{VM}=require('vm2');constvm=newVM({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 通訊。
針對沙箱進程進行CPU和內存配額限制
在Linux 平台,通過CGoups 對沙箱進程進行整體的CPU 和內存等資源的配額限制,Cgroups 是Control Groups 的縮寫,是Linux 內核提供的一種可以限制、記錄、隔離進程組(Process Groups)所使用的物理資源(如:CPU、Memory,IO 等等)的機制。最初由Google 的工程師提出,後來被整合進Linux 內核。 Cgroups 也是LXC 為實現虛擬化所使用的資源管理手段,可以說沒有CGroups 就沒有LXC。
最終,我們建立了一個大約這樣的「沙箱環境」

如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於TypeScript編寫,並封裝為一個獨立的模塊Safeify
。
相較於內建的VM 及常見的幾個沙箱模塊, Safeify 具有如下特點:
- 為將要執行的動態代碼建立專門的進程池,與宿主應用程序分離在不同的進程中執行
- 支持配置沙箱進程池的最大進程數量
- 支持限定同步代碼的最大執行時間,同時也支持限定包括異步代碼在內的執行時間
- 支持限定沙箱進程池的整體的CPU 資源配額(小數)
- 支持限定沙箱進程池的整體的最大的內存限制(單位m)
GitHub: https:// github.com/Houfeng/safe ify ,歡迎Star & Issues
最後,簡單介紹一下Safeify 如何使用,通過如下命令安裝
npm i safeify --save
在應用中使用,還是比較簡單的,如下代碼(TypeScript 中類似)
import{Safeify}from'./Safeify';constsafeVm=newSafeify({timeout:50,//超時時間,默認50msasyncTimeout:500,//包含異步操作的超時時間,默認500msquantity:4,//沙箱進程數量,默認同CPU核數memoryQuota:500,//沙箱最大能使用的內存(單位m),默認500mcpuQuota:0.5,//沙箱的cpu資源配額(百分比),默認50%});constcontext={a:1,b:2,add(a,b){returna+b;}};constrs=awaitsafeVm.run(`return add(a,b)`,context);console.log('result',rs);
關於安全的問題,沒有最安全,只有更安全,Safeify 已在一個項目中使用,但自定義腳本的功能是僅針對內網用戶,有不少動態執行代碼的場景其實是可以避免的,繞不開或實在需要提供這個功能時,希望本文或Safeify 能對大家有所幫助。
-- end --