如何优雅的实现一个表达式解释器?

2,459 阅读8分钟

在流程编排、规则引擎等场景下,我们经常会遇到对一段用户自定义表达式进行解释运行的需求。表达式语法可以很多样,以最近我遇到的一个需求场景为例,需要支持以下类型表达式的解释运行:

概念描述示例
常量表达式常量"THIS_IS_STRING"、98
变量表达式变量的引用${{custom_var}}
函数表达式表达式中使用函数${{!find(miniappConfig, {"node_tpl": "wx_app"})}}
运算符表达式表达式中使用运算符parameters.isDaily===true{{parameters.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 来对表达式解释器进行一个简陋的实现。

function interpret(code, ctx) {
    return eval(`with(ctx){${code}}`);
}

const ctx = { a: 1, b: 2, c: { d: 3 } };

const result = interpret(`a + c.d`, ctx);

console.log(result); // 4

eval() 存在两个明显的问题:

  • 安全性问题:eval() 是一个危险的函数,因为第三方代码可以访问到 eval() 被调用时的作用域,所以极易受到不同方式的攻击;
  • 性能问题:eval() 的执行必须要调用 JS 解释器将 JS 代码转换成机器码,这就意味着 eval() 将强制浏览器花费大量成本查找变量名。另外,类似修改变量类型的行为也会强制浏览器花费成本重新生成机器码。

此时,推荐使用 Function() 来代替,因为 Function 构造函数创建的函数只能在全局作用域中运行,所以相对 eval() 来说更安全且因为变量名查找上更加节约成本而相对性能更好;

另外,with + eval 实现方式还有个致命问题,就是如果访问指定上下文 ctx 没有的变量时,均会沿着 ctx 的作用域链向上查找变量。

示例如下:

function sandbox(code, ctx) {
    return eval(`with(ctx){${code}}`);
}

const ctx = { a: 1, b: 2, c: { d: 3 } };

const outerName = 'jack';
const a = 10;

sandbox(`a + '_' + outerName`, ctx); // 1_jack

因此,接下来,我们使用 with + Function 进行一个优化实现。

with + Function

下面是使用 with + Function 进行的一个表达式解释器实现

function interpret(code, ctx){
    return new Function('global', `with(global){return ${code}}`).call(ctx, ctx);
}

const ctx = { a: 1, b: 2, c: { d: 3 } };

interpret(`a + c.d`, ctx); // 4

with + Function 的实现方式,虽然相较 with + eval 方式相对更安全、性能更佳。

但是这种方式,也无法解决 with + eval 方式的致命问题,变量的访问会沿着作用域链逐级上找,直至访问到全局变量。从而导致其他作用域中的变量和方法极易受到篡改或其他方式的攻击。

为了解决该致命问题,Proxy 给我们提供了很好的解法。

with + Function + Proxy

下面介绍如何通过 with + Function + Proxy 的方式来实现表达式解释器,从而在实现机制上避免用户代码对其他作用域的访问和篡改。

function sandbox(code, ctx = {}) {

    const ctxWithProxy = new Proxy(ctx, {
        has: (target, prop) => {

            if (!target.hasOwnProperty(prop)) {
                throw new Error(`Invalid expression - ${prop}`);
            }

            return target.hasOwnProperty(prop);
        }
    });

    return new Function('global', `with(global){${code}}`).call(ctxWithProxy, ctxWithProxy);
}

const ctx = { a: 1, b: 2, c: { d: 3 } };

const e = 10;

sandbox(`return a + c.d`, ctx); // 4

sandbox(`return e`, ctx); // Error: Invalid expression - e

这种方式借助 Proxy 可以有效阻止用户代码对作用域链的访问,看起来能够有效隔离当前作用域和其他父级作用域。那么这种方式是完美的方式吗?我们后面会再进行详细分析。

Nodejs vm

如果你的代码是运行在 Node.js 中,那么还有一个更加简单的方法,就是 Node.js 的 vm 模块

const vm = require('vm');

const x = 1;

const context = { x: 2 };
vm.createContext(context); // Contextify the object.

const code = 'x += 40; var y = 17;';

vm.runInContext(code, context);

console.log(context.x); // 42
console.log(context.y); // 17

console.log(x); // 1; y is not defined.

该方法也能够有效阻断用户代码沿着作用域链进行非预期的访问和篡改。

vm 模块的使用是很简单的,但是 vm 确是官方不推荐的一种方法,因为 vm 是不安全的。

The node:vm module is not a security mechanism. Do not use it to run untrusted code.

存在的问题

上述几种实现方式,均能够在功能上满足对表达式解释器的诉求,但是均存在一个致命的问题,那就是安全性问题。主要有以下几种安全性问题:

沙箱逃逸

可通过原型链访问,完成沙箱逃逸,从而修改原生方法。

// 修改原生方法
sandbox(`({}).constructor.prototype.toString = () => { console.log('Escape!'); }; ({}).toString();`);

// 跳过 Proxy 限制执行非法代码
sandbox(`new arr.constructor.constructor('while(true){console.log("loop")}')()`, ctx)

退出进程

可以通过 constructor 访问 process,操作进程。

// 执行 process.exit()
sandbox('var x = this.constructor.constructor("return process")().exit()');

暴露环境变量

可以通过 constructor 暴露环境变量。

sandbox('var x = this.constructor.constructor("return process.env")()');

泄漏源码

可以通过 process 泄漏源码。

sandbox('var x = this.constructor.constructor("return process.mainModule.require('fs').readFileSync(process.mainModule.filename,'utf-8')")()');

执行命令行

可通过 process 执行命令行。

sandbox('var x = this.constructor.constructor("return process.mainModule.require('child_process').execSync('cat /etc/passwd',{encoding:'utf-8'})")()');

DoS 攻击

可通过循环语句 while 等进行 DoS 攻击。

sandbox('while(true){}');

一个不简陋的实现

设计思路

基于前文实现方式的问题分析,对方案进行优化设计,需要满足以下在安全管控、基础能力和能力拓展三个方面的需求。

安全管控

  • 避免宿主环境干扰:不允许访问/修改原型链(通过禁止访问 proto、prototype、constructor 等);
  • 禁止原生方法调用:
  • 禁止赋值、循环、条件判断语句:

基础能力

  • 操作符支持,支持算数运算符、关系运算符、逻辑操作符、符号优先级 & 括号优先级、三元运算符等;
  • 支持上下文访问、属性访问;

能力拓展

  • Lodash 方法支持,支持所有 Lodash 方法,实现对对象、数组、字符串等的基础操作:

如何实现一个解释器

要实现一个解释器,我们需要先了解如何实现一个解释器。

这里需要对解释器和编译器进行概念区分。

解释器(Interpreter)是一个基于用户输入执行源码的工具;编译器(Compiler)是将源码转换成另一种语言或机器码的工具。



解释器会解析源码,生成 AST(Abstract Syntax Tree)抽象语法树,逐个迭代并执行 AST 节点。


解释器有四个阶段:

  • 词法分析(lexical analysis)
  • 语法分析(syntax analysis)
  • 语义分析(semantic analysis)
  • 执行(evaluating)

词法分析

词法分析器读入组成源码的字符流,并且将它们组织成有意义的符号流。

字符流:

a + b / c.d.e

符号流:

+-----------------------+
| a | + | b | / | 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": "Identifier",
            "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 是否和语言定义的语义一致。如果出现不一致的情况,解释器就可以直接报错,阻断解释器解释并执行代码的过程。

执行

执行阶段会迭代整个 AST 抽象语法树,逐个执行各个 AST 节点。

示例如下:

5 + 7
|
    |
    v
+---+  +---+
| 5 |  | 7 |
+---+  +---+
  \     /
   \   /
    \ /
   +---+
   | + |
   +---+
{
    rhs: 5,
    op: '+'.
    lhs: 7
}

通过以上步骤,我们就实现了一个简单的解释器。

表达式解释器有以下应用场景:

  • 流程编排:通过动态脚本来管理流程调度,例如,基于微服务来动态搭建流程。
  • 规则引擎:利用动态表达式来实时修改配置,例如,营销规则配置、审核流条件判断。
  • 脚本引擎:利用动态脚本来实现在线编辑器。

一个完美满足需求的实现 - Aexpr

基于上述设计思路和解释器实现逻辑,我实现了一个完美满足需求的实现 - Aexpr

Aexpr 是一个安全的 JavaScript 表达式解释器,支持运算符、上下文访问、属性访问和 Lodash 方法。

支持:

  • 数据类型:number/boolean/string/object/array
  • 运算符:
    • 数学运算符:+ - * /
    • 逻辑运算符:&& || > >= < <= === !==
    • 三元运算符:a ? b : c
  • 上下文访问
  • 属性访问
  • 函数:支持所有 Lodash 方法,但是禁止函数类型的入参,以避免用户进行原型链访问

Aexpr 的优势是:

  • 安全保障:

    • 通过有限的 AST 语义支持,避免原型链访问,以达到沙箱隔离的效果
    • 通过禁用 while 等循环语句,避免 DoS 攻击
    • 通过禁止赋值语句 =,避免用户对原生方法或变量的篡改
    • 通过禁止原生方法的调用,避免非预期的攻击
  • 能力拓展

    • 通过对所有 Lodash 方法的支持,满足了对对象、数组、字符串等数据类型的拓展支持

Aexpr 的 AST 抽象语法树生成借助了一个好用的工具 Jison。Jison 可以支持自定义 AST 的生成语法,来生成我们想要的 AST。

Aexpr 的使用示例:

const interpret = require('aexpr');

// invoke the function
// interpret(codeStr: string, context?: object);

// calculate
interpret('1 + 2 / 4 * 6');
interpret('1 + 2 / (4 * 6)');

// compare
interpret('1 > 2');
interpret('1 < 2');
interpret('1 <= 2');
interpret('2 <= 2');
interpret('2 === 2');

// logic
interpret('1 && 2');
interpret('1 || 0');

// context accessing && property accessing
interpret('a + b / c.d.e', { a: 2, b: 3, c: { d: { e: 5 } } });

// lodash functions
interpret(`_.has(obj, 'a.b')`, { obj: { a: { b: 1, c: 2 } } });
interpret('_.indexOf(arr, 1)', { arr: [1, 2, 3, 4] });

const arr = [
    { 'user': 'barney', 'active': false },
    { 'user': 'fred', 'active': false },
    { 'user': 'pebbles', 'active': true }
];

// not support function type input params to avoid prototype access
expect(interpret.bind(null, `_.findIndex(users, function(o) { return o.user == 'barney'; })`)).toThrow('Parse error');
// support ordinary input params
expect(interpret(`_.findIndex(users, { 'user': 'fred', 'active': false })`, {users: arr})).toBe(1);

参考资料