在一些应用中,咱们但愿给用户提供插入自定义逻辑的能力,好比 Microsoft 的 Office 中的 VBA
,好比一些游戏中的 lua
脚本,FireFox 的「油猴脚本」,可以让用户发在可控的范围和权限内发挥想象作一些好玩、有用的事情,扩展了能力,知足用户的个性化需求。git
大多数都是一些客户端程序,在一些在线的系统和产品中也经常也有相似的需求,事实上,在线的应用中也有很多提供了自定义脚本的能力,好比 Google Docs 中的 Apps Script
,它可让你使用 JavaScript
作一些很是有用的事情,好比运行代码来响应文档打开事件或单元格更改事件,为公式制做自定义电子表格函数等等。github
与运行在「用户电脑中」的客户端应用不一样,用户的自定义脚本一般只能影响用户自已,而对于在线的应用或服务来说,有一些状况就变得更为重要,好比「安全」,用户的「自定义脚本」必须严格受到限制和隔离,即不能影响到宿主程序,也不能影响到其它用户。npm
而 Safeify 就是一个针对 Nodejs 应用,用于安全执行用户自定义的非信任脚本的模块。segmentfault
咱们先看看一般都能如何在 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
中找的到,以达到「防逃逸」的目的。async
在浏览器中,还能够利用 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
的官方 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 大致是这样的
那么,咱们是否是能够尝试,将非受信代码,经过 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 具备以下特色:
GitHub: https://github.com/Houfeng/sa... ,欢迎 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 --