动态执行脚本为Node.js应用构建更安全的沙箱环境有哪些场景?在一些应用中,我们希望为用户提供插入自定义逻辑的能力,比如MicrosoftOffice中的VBA,一些游戏中的Lua脚本,FireFox的“油猴脚本”,让用户可以在权限范围内发挥想象做一些有趣有用的事情,拓展自己的能力,满足用户的个性化需求。大部分都是客户端程序,在一些在线系统和产品中也经常有类似的需求。事实上,许多在线应用程序还提供了自定义脚本的功能,例如GoogleDocs中的AppsScript,它可以让您使用JavaScript做一些非常有用的事情,例如运行代码以响应文档打开事件或单元格更改事件,制作自定义电子表格公式函数等。与“在用户计算机上”运行的客户端应用程序不同,用户定义的脚本通常只会影响用户自己。对于在线应用或服务,有些情况变得更加重要,比如“安全”,用户的“自定义脚本”必须严格限制和隔离,即不能影响宿主程序,也不能影响其他用户。Safeify是Nodejs应用程序安全执行用户定义的不受信任脚本的模块。如何安全地执行动态脚本?我们先来看看如何在JavaScript程序中动态执行一段代码?比如上面那段著名的evaleval('1+2')代码就可以顺利执行,没有任何问题。Eval是全局对象的函数属性。执行的代码与应用程序中的其他普通代码具有相同的权限。它可以访问“执行上下文”中的局部变量,也可以访问所有的“全局变量”。在这种场景下,它是一个非常危险的函数。我们再来看看Functon。通过Function构造函数,我们可以动态创建一个function然后执行constsum=newFunction('m','n','returnm+n');console.log(sum(1,2));也执行的很顺利Function生成的函数构造函数不会在创建它的上下文中创建闭包,一般在全局作用域中创建,运行函数时,只能访问自己的局部变量和全局变量,不能访问生成上下文的作用域由被调用的Function构造函数。就像一个站在地上,一个站在一张薄纸上,在这一幕中,几乎没有高下之分。结合ES6的新特性Proxy,可以更安全functionevaluate(code,sandbox){sandbox=sandbox||对象.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)')//Cannotreadproperty'log'ofundefined我们知道不管eval还是function,作用域都会执行时逐层向上查找。如果找不到就去全局,所以使用Proxy的原理就是让执行的代码在sandobx中找到,从而达到“反越狱”的目的。在浏览器中,iframe也可以用来创建更安全的隔离环境。本文以Node.js为主,这里不做过多讨论。在Node.js中,还有其他选择吗?在看到这个之前你可能已经想到了VM。它是Node.js默认提供的内置模块。VM模块在V8中提供了一系列用于虚拟化的API。在机器环境中编译和运行代码。JavaScript代码可以立即编译运行,也可以编译保存运行。constvm=require('vm');constscript=newvm.Script('m+n');constsandbox={m:1,n:2};constcontext=newvm.createContext(sandbox);脚本.runInContext(上下文);执行上面的代码,得到结果3。同时,通过vm.Script,还可以指定代码执行的“最大毫秒数”。如果指定时间超过指定时间,则终止执行并抛出异常try{constscript=newvm.Script('while(true){}',{timeout:50});....}catch(err){//打印超时日志console.log(err.message);}上面的脚本执行会失败,会检测到超时并抛出异常,然后将被TryCache捕获并记录。不过需要注意的是vm的timeout选项。是异步调用的时间,比如constscript=newvm.Script('setTimeout(()=>{},2000)',{timeout:50});....上面的代码不会在50ms后抛出异常,因为50ms以上的代码同步执行必须结束,setTimeout使用的时间不算,也就是说vm模块没有直接限制异步代码执行时间的方法。我们也不能使用计时器来检查超时,因为检查后无法停止正在运行的虚拟机。另外,在Node.js中看似通过vm.runInContext隔离了代码执行环境,其实很容易“逃逸”。constvm=require('vm');const沙盒={};常量脚本=新虚拟机。Script('this.constructor.constructor("返回过程")().exit()');const上下文=vm.创建上下文(沙箱);script.runInContext(上下文);执行上面的代码,宿主程序会立即“退出”,沙箱是在VM以外的环境中创建的,而VM中代码的this需要指向沙箱,那么//this.constructor就是外部对象构造函数constObjConstructor=this.constructor;//ObjConstructor的构造函数是外包的FunctionconstFunction=ObjConstructor.constructor;//创建一个函数,执行,返回全局进程globalobjectconstprocess=(newFunction('returnprocess'))();//退出当前进程process.exit();没有人希望用户用脚本挂掉应用程序。除了退出进入程序之外,您实际上还可以做更多的事情。有一个简单的方法可以避免通过this.constructor获取进程,如下:constvm=require('vm');//创建一个没有proto的空白对象作为沙箱constsandbox=Object.create(null);constscript=newvm.Script('...');constcontext=vm.createContext(sandbox);script.runInContext(context);但还是有风险的,由于JavaScript本身的动态特性,各种黑魔法防不胜防。事实上,Node.js的官方文档也提到了“不要将VM作为安全沙箱来执行任何不受信任的代码”。是否有更进一步的社区模块?社区中有一些运行不受信任代码的开源模块,如sandbox、vm2、jailed等,相比之下,vm2在各个方面都做了更多的安全工作,相对安全。从vm2的官方README可以看出,它基于Node.js内置的VM模块搭建了一个基本的沙箱环境,然后利用上面介绍的ES6Proxy技术来防止沙箱脚本逃逸。用同样的测试代码试试vm2const{VM}=require('vm2');newVM().run('this.constructor.constructor("returnprocess")().exit()');如上代码,没有成功结束宿主程序。vm2官方的REAME说“vm2是一个沙箱,可以完整执行Node.js中不受信任的代码”。但是,实际上我们还是可以做一些“坏”的事情,比如:const{VM}=require('vm2');constvm=newVM({timeout:1000,sandbox:{}});vm.run('newPromise(()=>{})');上面的代码永远不会执行,就像Node.js内置模块一样,vm2的timeout对于异步操作是无效的。同时vm2也不能额外传一个timer来检测超时,因为它没有办法终止正在执行的vm。这样会一点一点的消耗服务器的资源,导致你的应用挂掉。那么你可能会想,我们能不能在上面的沙盒中放一个假的Promise来禁用Promise呢?答案是提供一个“假”的Promise,但是没有办法禁止Promise,比如const{VM}=require('vm2');constvm=newVM({timeout:1000,sandbox:{Promise:function(){}}});vm.run('Promise=(asyncfunction(){})().constructor;newPromise(()=>{});');可以看到通过一行Promise=(asyncfunction(){})().constructor可以很方便的再次获取Promise。从另一个角度来看,也许有时候我们希望自定义脚本能够支持异步处理。如何构建更安全的沙箱?通过以上研究,我们还没有找到在Node.js中建立安全隔离沙箱的完美方案。其中vm2做了很多处理,是比较安全的方案,但是问题也很明显,比如异步无法检查超时的问题,宿主程序在同一个进程的问题.在没有进程隔离的情况下,VM创建的sanbox大致是这样的。那么,我们是否可以尝试通过vm2模块将不可信代码隔离出来,在独立的进程中执行呢?那么当执行超时的时候,这个孤立的进程会被直接kill掉,但是这里需要考虑以下几个问题:通过进程池,统一调度和管理沙箱进程如果执行任务,创建进程,销毁它在用完的时候,仅仅处理进程的开销就已经有点大了,不设置限制是不可能开启新的进程和宿主应用程序来抢资源的。然后,需要建立一个进程池。当所有任务到达后,会创建一个Script实例,先进入一个pending队列,然后直接将脚本实例保存defer对象返回,可以在调用处等待执行结果,然后沙盒master会调度执行master会根据项目进程的空闲程序,将脚本的执行信息,包括重要的ScriptId,发送给空闲的worker,worker执行完成后,将“结果+脚本信息”发回给master,master会通过ScriptId识别执行了哪个脚本,结果会被解析或者拒绝。这样“进程池”可以减少“进程创建和销毁的开销”,保证主机资源不被过度抢占。同时,当异步操作超时时,可以直接kill掉项目进程。同时,master会发现如果一个项目进程挂了,会马上创建一个替换进程。处理后的数据和结果,以及向沙箱公开的方法,进程之间如何通信,需要“动态代码”对数据进行处理,可以直接序列化,通过IPC发送给隔离的沙箱进程,执行结果也通过IPC进行序列化和传输。其中,如果要将方法暴露给沙箱,由于不在进程中,不方便将对解决方案的引用传递给沙箱。我们可以将宿主方法传递给沙箱worker,转化成一个“描述对象”,包含沙箱允许调用的方法信息,然后像其他数据一样发送给worker进程,worker接收接收到数据后,识别出“方法描述对象”,然后在worker进程中在沙箱对象上创建代理方法。proxy方法也是通过IPC与master通信。限制沙盒进程的CPU和内存配额在Linux平台上,使用CGroups来限制沙盒进程的整体CPU和内存配额。Cgroups是ControlGroups的缩写,是Linux内核提供的一种特性,可以限制、记录、隔离ProcessGroups使用的物理资源(如CPU、Memory、IO等)的机制。最初由Google工程师提出,后来被集成到Linux内核中。Cgroups也是LXC用来实现虚拟化的资源管理方式。可以说没有CGroups就没有LXC。最后,我们建立了一个这样的“沙盒环境”。这样处理不觉得麻烦吗?但是我们有一个更安全的沙箱环境,这些过程。作者基于TypeScript编写,封装为一个独立的模块Safeify。与内置的VM和几个常用的沙箱模块相比,Safeify有以下特点:为要执行的动态代码建立了一个专门的进程池,与宿主应用程序分离,在不同的进程中执行。支持配置沙箱进程池最大进程数支持限制同步代码的最大执行时间,也支持限制包括异步代码的执行时间支持限制沙箱进程池的整体CPU资源配额(十进制)支持限制沙盒进程池的整体最大内存限制(以m为单位)GitHub:https://github.com/Houfeng/sa...欢迎Star&Issues最后简单介绍一下Safeify的使用方法,安装npmisafeify--save在应用中通过如下命令使用起来比较简单,如下代码(TypeScript中类似)import{Safeify}from'./Safeify';constsafeVm=newSafeify({timeout:50,//timeout,默认50msasyncTimeout:500,//包含异步操作的Timeout,默认500msquantity:4,//沙箱进程数,默认与CPU核数相同memoryQuota:500,//沙盒可使用的最大内存(单位m),默认500mcpuQuota:0.5,//沙盒cpu资源配额(百分比),默认50%});constcontext={a:1,b:2,add(a,b){返回a+b;}};constrs=awaitsafeVm.run(`returnadd(a,b)`,context);console.log('result',rs);关于安全问题,没有最安全,只有更安全,Safeify曾经在一个项目中使用过,但是自定义脚本功能只针对内网用户。有许多可以避免的动态代码执行场景。如果这个功能无法避免或者确实有必要提供这个功能,希望这篇文章或者Safeify能够对你有所帮助。-结尾-
