大家好,我是万天。在流程编排、规则引擎等场景中,我们经常会遇到需要解释和运行自定义表达式的需求。表达式语法有很多种。以我最近遇到的一个需求场景为例,需要支持对以下类型表达式的解释和操作:概念描述示例常量表达式常量“THIS_IS_STRING”,98变量表达式变量引用${{custom_var}}函数使用inexpression表达式函数${{!find(miniappVersion,{\"node_tpl\":\"alipay_app\"})}}operatorexpressionexpression使用运算符${{参数。isDaily===true}},${{app.pubext&&app.pubext.type&&app.pubext.data}}结果输出参考结果输出${{stages.stage1.jobs.job1.steps.step1.outputs.result}}基于以上需求场景,我们可以得出我们需要的表达式解释器需要具备以下能力。支持的数据类型:String/Number/Boolean/Array/Object支持上下文访问支持属性访问支持运算符+-*/===!==>=<=&&||支持对象、数组、字符串的常用方法几种简单的实现为了实现上面的分析能力,我们先从能力实现的角度来看几个简单的实现。下面with+eval,表达式解释器的简单实现是基于with+eval。functioninterpret(code,ctx){returneval(`with(ctx){${code}}`);}constctx={a:1,b:2,c:{d:3}};constresult=interpret(`a+c.d`,ctx);控制台日志(结果);//4eval()有两个明显的问题:安全问题:eval()是一个危险的函数,因为第三方代码可以访问eval()的作用域,因此极易受到不同方式的攻击;性能问题:eval()的执行必须调用JS解释器将JS代码转换为机器码,这意味着eval()将迫使浏览器花费大量金钱查找变量名。另外,修改变量类型等行为也会强制浏览器有代价地重新生成机器码。此时建议改用Function(),因为Function构造函数创建的函数只能在全局范围内运行,所以比eval()更安全,性能更好,因为在变量名查找;另外,with+eval的实现还有一个致命的问题,就是在访问指定上下文ctx中不存在的变量时,会沿着ctx的作用域链向上查找该变量。示例如下:functionsandbox(code,ctx){returneval(`with(ctx){${code}}`);}constctx={a:1,b:2,c:{d:3}};constouterName='jack';consta=10;sandbox(`a+'_'+outerName`,ctx);//1_jack所以,接下来,我们使用with+Function来优化实现。with+Function以下是使用with+Function的表达式解释器实现functioninterpret(code,ctx){returnnewFunction('global',`with(global){return${code}}`).call(ctx,ctx);}constctx={a:1,b:2,c:{d:3}};解释(`a+c.d`,ctx);//4with+Function的实现,虽然比with+eval方法相对安全,性能也更好。但是这种方式并不能解决with+eval方式的致命问题。变量访问将在作用域链中向上搜索,直到访问到全局变量。因此,其他作用域中的变量和方法极易受到篡改或其他攻击。为了解决这个致命的问题,Proxy为我们提供了一个很好的解决方案。with+Function+Proxy下面介绍如何通过with+Function+Proxy实现表达式解释器,在实现机制上防止用户代码访问和篡改其他作用域。functionsandbox(code,ctx={}){constctxWithProxy=newProxy(ctx,{has:(target,prop)=>{if(!target.hasOwnProperty(prop)){thrownewError(`无效表达式-${prop}`);}returntarget.hasOwnProperty(prop);}});returnnewFunction('global',`with(global){${code}}`).call(ctxWithProxy,ctxWithProxy);}constctx={a:1,b:2,c:{d:3}};conste=10;沙箱(`returna+c.d`,ctx);//4sandbox(`returne`,ctx);//Error:Invalidexpression-e这个方法可以有效的阻止用户代码借助Proxy访问作用域链,看起来可以有效的隔离当前作用域和其他父作用域。那么这种方式是完美的方式吗?我们稍后会详细分析。Nodejsvm如果你的代码运行在Node.js中,那么有一个更简单的方法,就是Node.js的vm模块。constvm=require('vm');常量x=1;const上下文={x:2};虚拟机。创建上下文(上下文);//上下文化对象。const代码='x+=40;vary=17;';vm.runInContext(code,context);console.log(context.x);//42console.log(context.y);//17console.log(x);//1;y未定义。这种方法还可以有效地阻止用户代码沿着作用域链进行无意的访问和篡改。vm模块的使用很简单,但是vm确实是官方不推荐的方法,因为vm并不安全。node:vm模块不是安全机制。不要用它来运行不受信任的代码。存在的问题上述实现方法在功能上都可以满足表达式解释器的需求,但是每一种都存在一个致命的问题,那就是安全问题。主要存在以下安全问题:沙盒越狱可以通过原型链访问完成沙盒越狱,从而修改native方法。//修改原生方法sandbox(`({}).constructor.prototype.toString=()=>{console.log('Escape!');};({}).toString();`);//SkiptheProxylimittoexecuteillegalcodesandbox(`newarr.constructor.constructor('while(true){console.log("loop")}')()`,ctx)退出进程可以通过构造函数和操作过程。//执行process.exit()sandbox('varx=this.constructor.constructor("返回进程")().exit()');暴露环境变量可以通过构造函数暴露环境变量。sandbox('varx=this.constructor.constructor("returnprocess.env")()');泄漏源代码可以通过进程泄漏源代码。sandbox('varx=this.constructor.constructor("returnprocess.mainModule.require(\'fs\').readFileSync(process.mainModule.filename,\'utf-8\')")()');执行命令行您可以通过进程执行命令行。sandbox('varx=this.constructor.constructor("returnprocess.mainModule.require(\'child_process\').execSync(\'cat/etc/passwd\',{encoding:'utf-8'})")()');DoS攻击DoS攻击可以通过循环语句while等进行sandbox('while(true){}');一个不那么简单的实现设计思路基于前面实现方式的问题分析,方案的优化设计需要在安全控制、基础能力和能力扩展方面满足以下三个需求。避免宿主环境干扰的安全控制:不允许访问/修改原型链(通过禁止访问__proto__、原型、构造函数等);禁止native方法调用:禁止赋值、循环、条件判断语句:支持基本能力运算符,支持算术运算符、关系运算符、逻辑运算符、符号优先&括号优先、三元运算符等;支持上下文访问、属性访问;能够扩展Lodash方法支持,支持所有Lodash方法,实现对象、数组、字符串等基本操作如:如何实现解释器要实现解释器,我们首先要了解如何实现解释器。这里我们需要对解释器和编译器做一个概念上的区分。解释器(Interpreter)是一种根据用户输入执行源代码的工具;编译器(Compiler)是将源代码转换成另一种语言或机器代码的工具。解释器会解析源代码,生成一棵AST(AbstractSyntaxTree)抽象语法树,对AST节点逐个迭代执行。解释器有四个阶段:词法分析、句法分析、语义分析、求值和词法分析。词法分析器读取构成源代码的字符流,并将它们组织成有意义的符号流。a+b/c.d.e+--------------------+|一个|+|乙|/|c.d.e|+--------------------+解析解析将词法分析中生成的符号流转换为AST抽象语法树。AST可视化示例:AST数据结构示例:{"type":"Program","start":0,"end":13,"body":[{"type":"ExpressionStatement","start":0,"end":13,"expression":{"type":"BinaryExpression","start":0,"end":13,"left":{"type":"Identifier","start":0,"end":1,"name":"a"},"operator":"+","right":{"type":"BinaryExpression","start":4,"end":13,"left""":{"type":"标识符","start":4,"end":5,"name":"b"},"operator":"/","right":{"type":"MemberExpression","start":8,"end":13,"object":{"type":"MemberExpression","start"":8,"end":11,"object":{"type":"Identifier","start":8,"end":9,"name":"c"},"property":{"type":"Identifier","start":10,"end":11,"name":"d"},"computed":false,"optional":false},"property":{"type":"Identifier","start":12,"end":13,"name":"e"},"computed":false,"optional":false}}}}],"sourceType":"module"}语义分析语义分析会检查AST抽象语法树的语义,检查AST是否与语言定义的语义一致。如果不一致,解释器可以直接报错,阻止解释器解释和执行代码。.Execution执行阶段迭代整个AST抽象语法树,逐一执行每一个AST节点。示例:5+7||v+---++---+|5||7|+---++---+\/\/\/+---+|+|+---+{rhs:5,op:'+'.lhs:7}通过以上步骤,我们实现了一个简单的解释器。表达式解释器有以下应用场景:流程编排:通过动态脚本管理流程调度,例如基于微服务动态构建流程。规则引擎:使用动态表达式实时修改配置,如营销规则配置、审核流程条件判断等。脚本引擎:使用动态脚本实现在线编辑。一个完美满足需求的实现——Aexpr基于以上设计思路和解释器实现逻辑,我实现了一个完美满足需求的实现——Aexpr。Aexpr是一个安全的JavaScript表达式解释器,支持运算符、上下文访问、属性访问和Lodash方法。支持:数据类型:数字/布尔值/字符串/对象/数组运算符:数学运算符:+-*/逻辑运算符:&&||>>=<<====!==三元运算符:a?b:ccontextaccessattributeaccessfunction:支持Lodash所有方法,但禁止函数类型的入口,避免用户访问原型链。Aexpr的优点是:安全保障:通过有限的AST语义支持,避免原型链访问,从而达到沙箱隔离的效果,通过禁用while等循环语句避免DoS攻击。通过禁用赋值语句=,来防止用户篡改本地方法或变量。通过禁止调用本地方法,避免意外攻击。对所有Lodash的扩展方法的支持,满足了对对象、数组、字符串等数据类型的扩展。Aexpr的AST抽象语法树是借助一个好用的工具Jison生成的。Jison可以支持自定义AST生成语法来生成我们想要的AST。Aexpr的使用示例:constinterpret=require('aexpr');//调用函数//interpret(codeStr:string,context?:object);//calculateinterpret('1+2/4*6');interpret('1+2/(4*6)');//比较解释('1>2');解释('1<2');解释('1<=2');解释('2<=2');interpret('2===2');//logicinterpret('1&&2');interpret('1||0');//contextaccessing&&propertyaccessinginterpret('a+b/c.d.e',{a:2,b:3,c:{d:{e:5}}});//lodash函数interpret(`_.has(obj,'a.b')`,{obj:{a:{b:1,c:2}}});interpret('_.indexOf(arr,1)',{arr:[1,2,3,4]});constarr=[{'user':'barney','active':false},{'user':'fred','active':false},{'user':'pebbles','active':true}];//不支持函数类型输入参数以避免原型accessexpect(interpret.bind(null,`_.findIndex(users,function(o){returno.user=='barney';})`)).toThrow('Parseerror');//支持普通输入paramsexpect(interpret(`_.findIndex(users,{'user':'fred','active':false})`,{users:arr})).toBe(1);参考资料用Acorn作为Parser在JavaScript中构建一个JS解释器从编译原理看解释器的实现表达式引擎介绍java脚本引擎设计原理介绍Drools、IKEExpression、Aviator和Groovy字符串表达式求值分析比较Java动态脚本&规则引擎,计算/表达式类型引擎jison使用bison做语法分析编译器基本流程前端技术——JS沙箱(JS隔离)的几种方案带来的思考和展望浅谈几种微实现方式-前端JS沙箱
