模拟Vue中JS动态表达式在模版中被动态解析的实现
动态脚本,在每种编程语⾔都有涉及,⽐如微软的 Office ⾥⾯的 VBA 脚本,⽐如浏览器插件的 Tampermonky,⽐如很多在线⼯具类⽹站的脚本在线执⾏,甚⾄在国外很⽕的 SaaS 以及其衍⽣平台中的开发者功能都是可以通过动态脚本来实现的。
⽽动态脚本也⼤体分为两种,⼀是在⽤户客户端⾥执⾏的代码,⽐如 Tampermonkey ,它可以让⽤户使⽤各种⾃定义的代码来完成特定的功能,⽽这些动态脚本都有⼀个局部性就是只在指定客户端⽣效。
⽽第⼆种就是运⾏在服务端的动态脚本。由于客户端的脚本功能有限,很多强⼤的功能,⽐如操作⽂件,访问数据库都是没法访问的,为了解决这种问题,很多 SaaS 或其它平台为⽤户提供了⾃定义开发的功能,⽽实现⾃定义开发,最快捷的就是运⾏⽤户⾃⼰的动态脚本,让⽤户可以操作⽂件,甚⾄部署⼀个服务端的服务。
但是,动态脚本虽然好,如果执⾏了⼀些恶意脚本,⽆论是对客户端还是服务端来说都是⼀场噩梦,轻则数据泄露,重则整个应⽤服务都会被影响导致崩溃。
那么,在Javascript中,怎样才能去动态并且安全地执⾏脚本呢?
eval和new Function
这⼆者在 Javascript 中是⽆论客户端还是服务端都可以使⽤的函数,可以⽤他们去动态解析并执⾏动态脚本,但是由于⽤他们执⾏的代码拥有着和应⽤中其它正常代码⼀样的的权限,能访问「执⾏上下⽂」中的局部变量,也能访问所有「全局变量」,在服务端的环境下,使⽤它们其实是⾮常危险的。
以 eval 为例,在服务器环境下执⾏以下代码
eval('it()')
对于服务器环境⽽⾔,process 是⼀个全局变量,上诉代码会让整个应⽤直接退出,简单点说,⼀旦运⾏了以上脚本,我们的 NodeJS 服务就挂了。
当然,如果将 eval 和 ES6 中的 Proxy 结合使⽤,可以限制⼀些上下⽂或者全局变量的访问,⽐如以下代码
function evalute(code,sandbox){
sandbox = sandbox || ate(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
这段代码会通过 Proxy 去阻⽌脚本获取上下⽂的变量,从⽽让动态脚本变得更安全了⼀些,不过由于使⽤到了 with 关键字,其性能也相对较差。
NodeJS中的其它选择?
如果只讨论服务端也就是 NodeJS,其⾃带的模块中有⼀个名为 VM 的模块,VM 模块提供了⼀系列 API ⽤于在 V8 虚拟机环境中编译和运⾏代码。Javascript 代码可以被编译并⽴即运⾏,或编译、保存然后再运⾏。以下是官⽅提供的例⼦:
const vm =require('vm');
const x =1;
const context ={ x:2};
const code ='x += 40; var y = 17;';
// `x` and `y` are global variables in the context.
// Initially, x has the value 2 because that is the value
vm.runInContext(code, context);
console.log(context.x);// 42
console.log(context.y);// 17
console.log(x);// 1; y is not defined.
从上述代码中可以看到,VM 模块可以很⽅便地执⾏⼀块动态脚本,并且还可以为其指定上下⽂并获取到执⾏后上下⽂变量的变化。
除此之外,VM 还可以指定⼀个参数 timeout, 如果执⾏超时会抛出⼀个异常,如下:
try{
const script =new vm.Script('while(true){}',{ timeout:50});
script.runInContext({});
// ....
}catch(err){
//打印超时的 log
console.ssage);
}
while(true){} 将在 50ms 之后报错并捕获到异常,打印出来。但需要注意的是这⾥的 timeout 只会对于同步的代码⽣效,如果使⽤异步代码如下:
const script =new vm.Script('setTimeout(()=>{},2000)',{ timeout:50});
那么 50ms 的限制将如同摆设。
另外,VM 模块中的 runInContext 看起来可隔离上下⽂,实际上很容易通过⼀些特殊的写法获取到上下⽂的变量,⽐如:
const vm =require('vm');
vm.runInNewContext('structor("return process")().exit()');
console.log('Never gets executed.');
通过运⾏以上代码,我们会发现,我们 log 的内容并没有出现,脚本⽚段中的 structor("return process")().exit() 获取到了process 对象,像我之前说的那样,直接将整个应⽤结束了。
就像 NodeJS 官⽹⽂档中说的那样:The vm module is not a security mechanism. Do not use it to run untrusted code.
由于 Javascript 本⾝过于动态,官⽅并不推荐我们通过 VM 模块去运⾏不受信任的代码。
那我们就没办法在Javascript中运⾏不受信任的代码了?
实际上并不是,在开源社区中我们可以看到,有很多开源的模块都可以⽤于运⾏不受信任的代码,⽐如 vm2, sandbox, jailed 等。
⽽其中安全性较⾼并且功能更多的要数 vm2
vm2 的官⽅⽂档上有提到,其基于 VM 模块,并且通过 Proxy 以及其它⽅法从多⾓度来防⽌对上下⽂变量的访问,同样⽤ vm2 来运⾏以下代码:
const{VM}=require('vm2');
new VM().run('structor("return process")().exit()');
// Throws ReferenceError: process is not defined
可以看到会抛出错误: process is not defined
在功能性上,vm2 还内置⼀个 NodeVM 的模块,通过这个模块我们甚⾄可以在脚本中引⼊外部的依赖,并且可以限制哪些依赖是可以引⼊的,哪些是不可以引⼊的,如以下例⼦:
const{NodeVM}=require('vm2');
const vm =new NodeVM({
require:{
external:true
}
});
vm.run(`
var request = require('request');
request('le', function (error, response, body) {
<(error);
if (!error && response.statusCode == 200) {
console.log(body) // Show the HTML for the Google homepage.
}
})
`,'vm.js');
但是,我们还是可以在 vm2 中写⼀些恶意代码
1. 由于 vm2 中的 NodeVM 不⽀持 timeout 属性,while(true){} 会阻塞整个应⽤
2. 即便在 vm2 中的 VM 模块可以指定 timeout,和 NodeJS 原⽣ VM ⼀样,由于 timeout 不能对异步代码⽣效,⼀旦运⾏异步代
码,timeout 便失效了
那怎么解决这个问题呢?
很多⼈看到这应该都能想到,可以通过进程去运⾏代码,然后如果超时便结束进程。是的,有了思路,发现 github 上有⼀个库叫做 safeify 正是通过这种思路来对 vm2 加了⼀层封装,其核⼼思路⼤致如下:
1. 通过沙箱在⼦进程中运⾏脚本
2. 通过进程池统⼀调度管理沙箱进程
3. 处理的数据和结果返回给主进程
4. 针对沙箱进程进⾏ CPU 、内存以及超时的限制
其中限制 CPU 和内存是通过 Linux 上的 CGoups 实现的
然⽽通过使⽤这个库,我⼜发现了很多问题
1. 其封装的是 vm2 中的 VM 模块⽽不是 NodeVM 模块,像在脚本中引⼊其它依赖是没法实现了
2. 由于进程通信的限制,该模块将指定 context 中的⽅法的运⾏还是放在主进程中运⾏,没有完全的异步交给⼦进程
tampermonkey
const safeVm =new Safeify({
timeout:50,//超时时间,默认 50ms
asyncTimeout:1000,//包含异步操作的超时时间,默认 500ms
quantity:2,//沙箱进程数量,默认同 CPU 核数
memoryQuota:100,//沙箱最⼤能使⽤的内存(单位 m),默认 500m
cpuQuota:0.1,//沙箱的 cpu 资源配额(百分⽐),默认 50%
});
const context ={
a:1,
b:1,
add(a, b){
while(true){
// console.log(b)
}
return a + b;
}
};
(async function f(){
setTimeout(()=>{
console.log('')
},2000)
const rs =await Promise.all(
[
safeVm.run(`return add(a,1)`, context),
]
)
console.log('result', rs);
// 释放资源
safeVm.destroy();
})();
像这样的逻辑会⼀直卡住主进程,timeout 也会失效
3. 会在初始化时就实例化出配置的⼦进程,如果是4,就会实例化4条,对资源占⽤很不友好,并且需要⼿动调⽤ safeify.destory() ⽅法
去销毁⼦进程,由于执⾏脚本是异步的,对销毁时机需要很好的把握,⼀不⼩⼼就把还没执⾏完的⼦进程销毁掉了
研究了⼀下其代码以及其逻辑,感觉并不复杂,于是决定重写⼀个库,以解决以上问题
VM Guard for NodeJS
vm-guard 是⼀个可以解决我⽂中所诉的所有痛点的库,其基于 vm2 中的 NodeVM,是⼀个⽤于 NodeJS 的沙箱运⾏环境
开源地址/⽂档:
相对于vm2中的NodeVM解决的问题
1. 多进程增加运⾏沙箱代码的运⾏速率
2. 新增 timeout (超时)可遏制在 NodeJS 环境下 VM2 所不能解决的恶意代码,如:
while(true){}
相对于safeify解决的问题:
1. 使⽤ NodeVM 模块,可以⽀持更丰富的脚本⾃定义功能,并且和 vm2 中的 NodeVM 模块的配置属性完全兼容
2. 在 vm-guard 中,我们不需要在传给脚本的 context ⾥声明⽅法(也不能,也没必要),通过 NodeVM 模块我们可以指定依赖,并且
依赖也是会运⾏在⼦进程中的
3. 动态的进程管理,在 vm-guard 中,会动态的管理⼦进程,在没运⾏脚本时,不会开启任何⼦进程,如果需要运⾏脚本,会开启不⼤
于设置数量的⼦进程来分别运⾏脚本,同时运⾏多个脚本时,未运⾏的脚本会在队列中等待空闲进程,⼀旦所有脚本运⾏完毕,便会⾃动清理进程
参考项⽬
参考⽂章
原⽂转⾃ 未经允许禁⽌转载

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。