這次帶給大家如何使用Node.js設定沙箱環境,使用Node.js設定沙箱環境的注意事項有哪些,下面就是實戰案例,一起來看一下。
有哪些動態執行腳本的場景?
在某些應用程式中,我們希望提供使用者插入自訂邏輯的能力,例如Microsoft 的Office 中的VBA,例如一些遊戲中的lua 腳本,FireFox 的「油猴腳本」,能夠讓使用者發在可控的範圍和權限內發揮想像做一些好玩、有用的事情,擴展了能力,滿足用戶的個人化需求。
大多數都是一些客戶端程序,在一些在線的系統和產品中也常常也有類似的需求,事實上,在線的應用中也有不少提供了自定義腳本的能力,比如穀歌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 建構器產生的函數,並不會在創建它的上下文中創建閉包,一般在全域作用域中被創建。當運行函數的時候,只能存取自己的本地變數和全域變量,不能存取 Function 構造器被呼叫產生的上下文的作用域。如同一個站在地上、一個站在一張薄薄的紙上一樣,在這個場景下,幾乎沒有高下之分。
結合ES6 的新特性Proxy 便能更安全一些
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.Script 的timeout選項「只針對同步代有效」,而不包括是異步調用的時間,例如
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 当做一个安全的沙箱去执行任意非信任的代码。
有哪些做了进一步工作的社区模块?
在社区中有一些开源的模块用于运行不信任代码,例如 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);
相信看了本文案例你已经掌握了方法,更多精彩请关注php中文网其它相关文章!
推荐阅读:
以上是如何使用Node.js設定沙箱環境的詳細內容。更多資訊請關注PHP中文網其他相關文章!