在一些系統中,我們希望給用戶提供插入自定義邏輯的能力,除了 RPC
和 REST
之外,運行客戶提供的代碼也是比較常用的方法,好處是可以極大地減少在網絡上的耗時。JavaScript 是一種非常流行而且容易上手的語言,因此,讓用戶用 JavaScript 來寫自定義邏輯是一個不錯的選擇。下面我們介紹 Node.js 提供的 vm 模塊以及分析用它來運行不信任代碼可能遇到的問題。
vm 模塊
vm 模塊是 Node.js 內置的核心模塊,它能讓我們編譯 JavaScript 代碼和在指定的環境中運行。請看下面例子:
const util = require('util'); const vm = require('vm'); // 1. 創建一個 vm.Script 實例, 編譯要執行的代碼 const script = new vm.Script('globalVar += 1; anotherGlobalVar = 1; '); // 2. 用於綁定到 context 的對象 const sandbox = {globalVar: 1}; // 3. 創建一個 context, 並且把 sandbox 這個對象綁定到這個環境, 作為全局對象 const contextifiedSandbox = vm.createContext(sandbox); // 4. 運行上面編譯的代碼, context 是 contextifiedSandbox const result = script.runInContext(contextifiedSandbox); console.log(`sandbox === contextifiedSandbox ? ${sandbox ===www.bsck.org contextifiedSandbox}`); // sandbox === contextifiedSandbox ? true console.log(`sandbox: ${util.inspect(sandbox)}`); // sandbox: { globalVar: 2, anotherGlobalVar: 1 } console.log(`result: ${util.inspect(result)}`); // result: 1
vm.Script
是一個類,用於創建代碼實例,后面可以多次運行。
vm.createContext(sandbox)
用於 "contextify" 一個對象,根據 ECMAScript 2015 語言規范,代碼的執行需要一個 execution context。這里的 "contextify",就是把傳進去的對象與 V8 的一個新的 context 進行關聯。這里所說的關聯,我的理解是,這個 "contextified" 對象的屬性將會成為那個 context 的全局屬性,同時,在 context 下運行代碼時產生的全局屬性也會成為這個 "contextified" 對象的屬性。
script.runInContext(contextifiedSandbox)
就是使代碼在 contextifiedSandbox
這個 context 中運行,從上面的輸出可以看到,代碼運行后,contextifiedSandbox
里面的屬性的值已經被改變了,運行結果是最后一個表達式的值。
除了上面幾個接口之外,vm 模塊還有一些更便捷的接口,例如 vm.runInContext(code, contextifiedSandbox[,www.90168.org options])
,vm.runInNewContext(code[, sandbox][, options])
等,詳細可看文檔。
外層如何得到代碼運行結果
我們用 vm 運行代碼的時候很可能需要得到一些結果,從上面的例子中可以看到,我們可以通過把結果作為最后一個表達式的值傳給外層,或者作為context
的屬性給外層使用,這在同步代碼里沒有問題,但是假如結果需要依賴里面的異步操作呢?這時,我們可以通過在 context
里放一個回調函數。 下面是例子:
const util = require('util'); const vm = require('vm'); const sandbox = {globalVar: 1, setTimeout: setTimeout, cb: function(result) { console.log(result); }}; vm.createContext(sandbox); const script = new vm.Script(` setTimeout(function(){ globalVar++; cb("async result"); }, 1000); `,{}); script.runInContext(sandbox); console.log(`globalVar: ${sandbox.globalVar}`); // globalVar: 1 // async result
代碼運行時間限制
script.runInContext(contextifiedSandbox[, options])
方法有一個 timeout
選項可以設定代碼的運行時間,如果超過時間就會拋出錯誤,請看下面例子:
const util = require('util'); const vm = require('vm'); const sandbox = {}; const contextifiedSandbox = vm.createContext(sandbox); const script = new vm.Script('while(true){}'); const result = script.runInContext(contextifiedSandbox, {timeout: 1000}); // const result = script.runInContext(contextifiedSandbox, {timeout: 1000}); // ^ // Error: Script execution timed out.
再試試異步代碼,
const util = require('util'); const vm = require('vm'); const sandbox = {globalVar: 1, setTimeout: setTimeout, cb: function(result) { console.log(result); }}; vm.createContext(sandbox); const script = new vm.Script(` setTimeout(function(){ globalVar++; cb("async result"); }, 1000); globalVar; `,{}); const result = script.runInContext(sandbox, {timeout: 500}); console.log(`result: ${result}`); // result: 1 // async result
沒有錯誤拋出,也就是說,這個選項並不能限制異步代碼的運行時間,那應該怎么去限制所有代碼的執行時間呢,目前好像沒有接口終止 vm 代碼的運行,如果有異步代碼長時間不結束,很容易造成內存泄露,目前可行的方案是使用子進程去運行代碼,如果超過新視覺限定時間還沒有結果,就殺掉該子進程,另外,使用子進程還可以更方便地對內存等資源進行限制。
定制 context 與安全問題
在一個全新的 V8 context 里運行代碼,里面包含了語言規范規定的內置的一些函數和對象,如果我們想要一些語言規范之外的功能或者模塊,我們需要把相應對象放到與這個 context 關聯的對象里,例如在上面例子中的這句代碼:
const sandbox = {globalVar: 1, setTimeout: setTimeout, cb: function(result) { console.log(result); }};
setTimeout
不是語言規范規定的內置函數, context 本身不提供,所以我們需要通過關聯的對象傳進去。
然而,當我們把一些模塊功能提供給 context 的時候,也同時帶入了更多的安全隱患,請看下面來自例子:
const util = require('util'); const vm = require('vm'); const sandbox = {}; vm.createContext(sandbox); const script = new vm.Script(` // sandbox 的 constructor 是外層的 Object 類 // Object 類的 constructor 是外層的 Function 類 const OutFunction = this.constructor.constructor; // 於是, 利用外層的 Function 構造一個函數就可以得到外層的全局 this const OutThis = (OutFunction('return this;'))(); // 得到 require const require = OutThis.process.mainModule.require; // 試試 require('fs'); `,{}); const result = script.runInContext(sandbox); console.log(result === require('fs')); // true
顯然,定制 context 的時候,任何一個傳進去的對象或者函數都可能帶來上面的問題,安全問題真的有很多工作需要做。
Github 上有一些開源的模塊用於運行不信任代碼,例如 sandbox,vm2,jailed等。查看這些項目的 issue 可以發現,sandbox 和 jailed 都可以用類似上面的方法突破限制,而 vm2 對這方面做了防護,其它方面也做了更多的安全工作,相對安全些。
生產中光棍影院可以考慮在子進程中運行 vm2, 然后增加更低層的安全限制, 例如限制進程的權限和使用 cgroups 進行 IO,內存等資源限制,這里不詳細討論。
總結
本文通過幾個例子介紹了 Node.js 的 vm 模塊以及使用 vm 模塊運行不信任代碼可能遇到的問題,並且對安全問題給出了一些建議。