爲 Node.js 應用創建一個更安全的沙箱環境

1

有哪些動態執行腳本的場景?

在一些應用中,咱們但願給用戶提供插入自定義邏輯的能力,好比 Microsoft 的 Office 中的 VBA,好比一些遊戲中的 lua 腳本,FireFox 的「油猴腳本」,可以讓用戶發在可控的範圍和權限內發揮想象作一些好玩、有用的事情,擴展了能力,知足用戶的個性化需求。git

大多數都是一些客戶端程序,在一些在線的系統和產品中也經常也有相似的需求,事實上,在線的應用中也有很多提供了自定義腳本的能力,好比 Google Docs 中的 Apps Script,它可讓你使用 JavaScript 作一些很是有用的事情,好比運行代碼來響應文檔打開事件或單元格更改事件,爲公式製做自定義電子表格函數等等。github

與運行在「用戶電腦中」的客戶端應用不一樣,用戶的自定義腳本一般只能影響用戶自已,而對於在線的應用或服務來說,有一些狀況就變得更爲重要,好比「安全」,用戶的「自定義腳本」必須嚴格受到限制和隔離,即不能影響到宿主程序,也不能影響到其它用戶。npm

而 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 構造器生成的函數,並不會在建立它的上下文中建立閉包,通常在全局做用域中被建立。當運行函數的時候,只能訪問本身的本地變量和全局變量,不能訪問 Function 構造器被調用生成的上下文的做用域。如同一個站在地上、一個站在一張薄薄的紙上同樣,在這個場景下,幾乎沒有高下之分。異步

結合 ES6 的新特性 Proxy 便能更安全一些async

function evalute(code,sandbox) {
  sandbox = sandbox || Object.create(null);
  const fn = new Function('sandbox', `with(sandbox){return (${code})}`);
  const proxy = new Proxy(sandbox, {
    has(target, key) {
      // 讓動態執行的代碼認爲屬性已存在
      return true; 
    }
  });
  return fn(proxy);
}
evalute('1+2') // 3
evalute('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 代碼能夠被編譯並當即運行,或編譯、保存而後再運行。

const vm = require('vm');
const script = new vm.Script('m + n');
const sandbox = { m: 1, n: 2 };
const context = new vm.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.Scripttimeout 選項「只針對同步代有效」,而不包括是異步調用的時間,好比

const script = new vm.Script('setTimeout(()=>{},2000)',{ timeout: 50 });
  ....
複製代碼

上述代碼,並非會在 50ms 後拋出異常,由於 50ms 上邊的代碼同步執行確定完了,而 setTimeout 所用的時間並不算在內,也就是說 vm 模塊沒有辦法對異步代碼直接限制執行時間。咱們也不能額外經過一個 timer 去檢查超時,由於檢查了執行中的 vm 也沒有方法去停止掉。

另外,在 Node.js 經過 vm.runInContext 看起來彷佛隔離了代碼執行環境,但實際上卻很容易「逃逸」出去。

const vm = require('vm');
const sandbox = {};
const script = new vm.Script('this.constructor.constructor("return process")().exit()');
const context = 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 當作一個安全的沙箱,去執行任意非信任的代碼」。

有哪些作了進一步工做的社區模塊?

在社區中有一些開源的模塊用於運行不信任代碼,例如 sandboxvm2jailed 等。相比較而言 vm2 對各方面作了更多的安全工做,相對安全些。

vm2 的官方 README 中能夠看到,它基於 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 大致是這樣的

2

那麼,咱們是否是能夠嘗試,將非受信代碼,經過 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。

最終,咱們創建了一個大約這樣的「沙箱環境

3

如此這般處理起來是否是感受很麻煩?但咱們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝爲一個獨立的模塊 Safeify

相較於內建的 VM 及常見的幾個沙箱模塊, Safeify 具備以下特色:

  • 爲將要執行的動態代碼創建專門的進程池,與宿主應用程序分離在不一樣的進程中執行
  • 支持配置沙箱進程池的最大進程數量
  • 支持限定同步代碼的最大執行時間,同時也支持限定包括異步代碼在內的執行時間
  • 支持限定沙箱進程池的總體的 CPU 資源配額(小數)
  • 支持限定沙箱進程池的總體的最大的內存限制(單位 m)

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 核數
  memoryQuota: 500,     //沙箱最大能使用的內存(單位 m),默認 500m
  cpuQuota: 0.5,        //沙箱的 cpu 資源配額(百分比),默認 50%
});

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 能對你們有所幫助就好了。

-- end --

相關文章
相關標籤/搜索