资讯详情 Python eval不安全,用AST白名单构建安全表达式求值器
📅 2026/10/15 8:37:12
标题里的95准确说是我笔记里的条目号。当时团队要做一个工具类项目运营同学需要在配置页直接填计算公式类似“本月收入 / 上月收入 - 1”这种后端拿到字符串必须安全地算出数值结果。群里第一时间想到的是Python的eval好在那天我没直接写进代码而是先在本地跑了一行测试eval(__import__(os).system(whoami))终端直接弹出了当前用户名。真要把这种逻辑上线相当于给表达式的调用方递了一把打开服务器大门的钥匙。所以这个需求被我单独列进了项目笔记编号95最后用Python抽象语法树AST方案彻底解决。这篇文章就是完整复盘为什么eval不能碰、AST是怎么把字符串拆成一棵看得懂的树、怎么基于白名单节点构建一个安全的表达式求值函数以及后续实际使用中遇到的绕过手法和防御细节。如果你正在做规则引擎、低代码公式、动态配置计算这类需要“让用户填表达式”的功能这篇可以直接抄作业想深究原理的话每一步我也会把为什么这样做讲清楚。1. 为什么不能直接用eval()一次真实的翻车现场1.1 裸用eval的三种事故很多人觉得eval只是“算一下数学式子”但Python的eval执行的是完整表达式语义只要是表达式它能干的事情多到你不敢想。第一种事故是直接执行系统命令。攻击者只需要写一行eval(__import__(os).system(whoami))__import__是Python内置函数用它导入os模块再调用system执行命令整个过程就是一次标准的表达式求值。如果这个eval跑在服务端后果你自己想。第二种事故是沿着对象关系“越狱”。Python里几乎每个对象都能通过__class__拿到自己的类型顺着__mro__、__subclasses__()这些链一路摸下去可以摸到整个运行时环境里的任意类、任意模块。攻击者不需要直接写__import__(os)先拿一个整数对象做跳板就能绕开很多黑名单过滤(1).__class__.__bases__[0].__subclasses__()有人会问那我给eval传一个空的globals不就行了吗我见过很多项目这么干eval(expr, {__builtins__: {}})这个“伪防护”的漏洞在于你只是屏蔽了内置名字对象本身的方法和属性还都在。攻击者用上面的__class__链绕一圈照样能找到可执行代码的工具全局名字有没有根本不重要。第三种事故是拒绝服务。一个表达式2 ** 2 ** 100看起来只是几个字符但求值时会直接撑爆内存和CPU。这还不是最极端的类似10 ** 10 ** 10这种写法Python计算大整数时内存增长是指数级的服务一下子就没了。eval不会帮你限制表达式长度、节点数量、运算强度它只负责“执行”不负责“安全”。1.2 AST白名单为什么比黑名单可靠遇到安全拦截人的第一反应往往是列黑名单禁止__import__、禁止os、禁止system。但黑名单是防不完的。攻击者可以通过字符串拼接、编码绕过、对象链等方式绕开关键字过滤维护成本极高而且总会有漏网之鱼。AST方案的思路刚好反过来我不去猜哪些不能用而是明确列出“哪些能用”只有出现在白名单里的节点类型和操作符才被放行。这等于从语法层面就把危险操作挡死了。调用函数先看Call节点是否在白名单里不在就拒。访问对象属性Attribute节点直接禁用。在这个模型里攻击者根本活不到求值那一步表达式在解析成AST之后、执行之前就被拦截了。1.3 为什么不直接上现成沙箱库Python生态里确实有专门的受限执行方案比如某些专用库和社区维护的受限解释器。但我在实际选型时发现这类库大多年久失修跟新版本Python的兼容性参差不齐而且很多库的设计目标比我们需要的重得多文档晦涩、定制成本高。对于“安全计算一个四则混合表达式”这种具体需求自己基于AST写一个白名单求值器反而是性价比最高的路线。核心代码量不大逻辑完全可控出现新攻击手法时也能自己快速补规则。前提是你一定要把校验当成安全边界来写而不是“加个判断意思一下”。2. AST是什么先把表达式拆成一棵看得懂的树2.1 从源码到执行之间发生了什么Python执行一行代码并不是“字符串直接变结果”这么简单。大致过程是源码先被拆成词法单元再根据语法规则组装成一棵抽象语法树然后编译成字节码最后才交给解释器执行。抽象语法树就是中间那层“骨架”每个表达式、每个数字、每个操作符都有对应的节点。写个最简单的例子import ast tree ast.parse(amount * (rate 100), modeeval) print(ast.dump(tree, indent2))输出大概长这样Expression( bodyBinOp( leftName(idamount, ctxLoad()), opMult(), rightBinOp( leftName(idrate, ctxLoad()), opAdd(), rightConstant(value100))))看到没你填的公式被拆成了一棵嵌套的树最外层是Expression代表“这是一个表达式”中间BinOp是二元运算左边乘号左边是变量amount右边又是一个BinOp加法操作符把变量rate和常量100连起来。这棵树最大的好处是它把“代码”和“语义”结构化地暴露出来了。我可以遍历这棵树检查每一个节点是不是我允许的类型而不需要在字符串层面做模糊匹配。2.2 常见的AST节点类型速查写校验器之前先要认识主要的节点类型。下面这张表是我当时整理的也是后面白名单设计的基础节点类型含义是否常见于数学公式ast.Expression表达式根节点是ast.Constant数字、字符串、布尔等常量是ast.Name变量名引用是ast.BinOp二元运算加减乘除等是ast.UnaryOp一元运算正负号、not是ast.BoolOp布尔与或运算经常ast.Compare比较运算大于、小于等经常ast.IfExp三元表达式a if b else c偶尔ast.Call函数调用看需求ast.Attribute属性访问x.y基本不该出现ast.Subscript下标访问x[i]基本不该出现ast.Lambda匿名函数不该出现ast.ListComp列表推导式不该出现ast.NamedExpr海象运算符x : 1不该出现看到规律了吗安全的核心就是把右边“不该出现”的那一栏全部挡在外面尤其Call、Attribute、Subscript这三个几乎是所有绕过攻击的组合零件。2.3 遍历树的方法和坑Python提供ast.walk一行就能拿到所有子节点for node in ast.walk(tree): print(type(node).__name__)但它有两个问题需要注意。一是ast.walk是不管节点类型的深度遍历如果你中途想“剪枝”或者记录深度它帮不上忙二是如果你在遍历过程中直接修改树结构有可能产生奇怪的迭代行为。所以我在校验逻辑里选择自己写递归函数同时控制深度这样既能检查节点类型也能顺带统计节点数量、限制嵌套深度。3. 手写安全求值器从白名单校验到完整可运行代码3.1 第一步解析表达式坚持用eval模式不用exec模式先强调一个基础但重要的点解析表达式必须用modeeval不能用默认的modeexec。exec模式允许语句比如赋值、循环、函数定义、导入语句这些通通是我们不想要的。eval模式从语法上就把“语句”挡掉了大半还剩下海象运算符这类表达式级别的坏东西需要我们在节点白名单里处理。import ast try: tree ast.parse(expr, modeeval) except SyntaxError: raise ValueError(表达式语法不正确)这里有一个容易被忽略的坑ast.parse极长的字符串本身也可能消耗CPU和内存。所以在解析之前先做长度限制我当时定的阈值是200个字符正常业务公式几十个字符以内就能覆盖200已经是比较宽松的上限了。3.2 第二步写白名单校验器宁可错杀不可放过我的校验器核心就一件事遍历AST里的每一个节点凡是不在白名单里的节点直接拒绝。先定义白名单集合和全局限制ALLOWED_NODES { ast.Expression, ast.BinOp, ast.UnaryOp, ast.BoolOp, ast.Compare, ast.IfExp, ast.Name, ast.Constant, } ALLOWED_VARS {amount, rate, base, offset, score} ALLOWED_BIN_OP { ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, } ALLOWED_UNARY_OP {ast.UAdd, ast.USub, ast.Not} ALLOWED_COMPARE_OP { ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE, } MAX_EXPR_LEN 200 MAX_NODE_COUNT 64 MAX_DEPTH 32注意我默认把ast.Pow幂运算排除了。原因在第一章已经说过2 ** 100000这类表达式能直接打挂服务。如果业务真的需要幂运算我的建议是单独开放一个受控的pow函数参数范围由你自己的代码约束而不是放开运算符。校验函数长这样class UnsafeExpressionError(ValueError): pass def check_expression(tree): node_count 0 def walk(node, depth0): nonlocal node_count node_count 1 if node_count MAX_NODE_COUNT: raise UnsafeExpressionError(表达式节点数量超过限制) if depth MAX_DEPTH: raise UnsafeExpressionError(表达式嵌套过深) node_type type(node) if node_type not in ALLOWED_NODES: raise UnsafeExpressionError(f不支持的语法节点: {node_type.__name__}) if isinstance(node, ast.Name): if node.id not in ALLOWED_VARS: raise UnsafeExpressionError(f变量不在白名单: {node.id}) if not isinstance(node.ctx, ast.Load): raise UnsafeExpressionError(变量只能读取不能写入) if isinstance(node, ast.Constant): if not isinstance(node.value, (int, float, bool, type(None), str)): raise UnsafeExpressionError(常量类型不受支持) if isinstance(node.value, str) and len(node.value) 20: raise UnsafeExpressionError(字符串常量过长) if isinstance(node, ast.BinOp): if type(node.op) not in ALLOWED_BIN_OP: raise UnsafeExpressionError(f二元运算符不受支持: {type(node.op).__name__}) if isinstance(node, ast.UnaryOp): if type(node.op) not in ALLOWED_UNARY_OP: raise UnsafeExpressionError(f一元运算符不受支持: {type(node.op).__name__}) if isinstance(node, ast.Compare): for op_node in node.ops: if type(op_node) not in ALLOWED_COMPARE_OP: raise UnsafeExpressionError(f比较运算符不受支持: {type(op_node).__name__}) for child in ast.iter_child_nodes(node): walk(child, depth 1) walk(tree, 0)这个递归函数同时做了四件事检查节点类型、限制节点数量、限制嵌套深度、对特定节点做细粒度约束。比如Name节点只允许出现在白名单变量集合里Constant只允许基础类型操作符还要单独检查。后一条很多人会忽略你可能把ast.BinOp加进了白名单但忘了区分它下面挂的是加法还是位运算。理论上位运算符、|也能被算出来放在自己的公式引擎里往往是业务不需要的应该一并拦掉。3.3 第三步写求值器先自己解释执行不要图快校验通过之后有两种执行路线。路线A是“校验通过后编译执行”性能最好但要求你对自己的校验器有绝对信心。路线B是“自己写一个AST解释执行器”速度慢一点但每一步都是自己的代码行为和异常完全可控。我的建议是先做路线B把安全边界吃透性能不够再切到路线A。下面是一个完整的解释执行器基于上面的校验函数配合使用import operator class SafeEvaluator: def __init__(self, env): self.env env def eval(self, node): if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.Name): if node.id not in self.env: raise UnsafeExpressionError(f未知变量: {node.id}) return self.env[node.id] if isinstance(node, ast.BinOp): left self.eval(node.left) right self.eval(node.right) return self._binary_op(node.op, left, right) if isinstance(node, ast.UnaryOp): operand self.eval(node.operand) return self._unary_op(node.op, operand) if isinstance(node, ast.BoolOp): return self._bool_op(node.op, node.values) if isinstance(node, ast.Compare): return self._compare(node) if isinstance(node, ast.IfExp): test_value self.eval(node.test) return self.eval(node.body if test_value else node.orelse) raise UnsafeExpressionError(f不支持的节点: {type(node).__name__}) def _binary_op(self, op, left, right): try: if isinstance(op, ast.Add): return left right if isinstance(op, ast.Sub): return left - right if isinstance(op, ast.Mult): return left * right if isinstance(op, ast.Div): return left / right if isinstance(op, ast.Mod): return left % right if isinstance(op, ast.Pow): result left ** right if isinstance(result, (int, float)) and abs(result) 1e100: raise UnsafeExpressionError(运算结果超出允许范围) return result except ZeroDivisionError as e: raise UnsafeExpressionError(除零错误) from e except (TypeError, ArithmeticError) as e: raise UnsafeExpressionError(运算失败) from e raise UnsafeExpressionError(f二元运算符不受支持: {type(op).__name__}) def _unary_op(self, op, operand): try: if isinstance(op, ast.UAdd): return operand if isinstance(op, ast.USub): return -operand if isinstance(op, ast.Not): return not operand except TypeError as e: raise UnsafeExpressionError(一元运算失败) from e raise UnsafeExpressionError(f一元运算符不受支持: {type(op).__name__}) def _bool_op(self, op, values): if isinstance(op, ast.And): result True for node in values: result self.eval(node) if not result: return result return result if isinstance(op, ast.Or): for node in values: result self.eval(node) if result: return result return result raise UnsafeExpressionError(f布尔运算符不受支持: {type(op).__name__}) def _compare(self, node): left self.eval(node.left) for op_node, comparator_node in zip(node.ops, node.comparators): right self.eval(comparator_node) if not self._compare_op(op_node, left, right): return False left right return True def _compare_op(self, op, left, right): try: if isinstance(op, ast.Eq): return left right if isinstance(op, ast.NotEq): return left ! right if isinstance(op, ast.Lt): return left right if isinstance(op, ast.LtE): return left right if isinstance(op, ast.Gt): return left right if isinstance(op, ast.GtE): return left right except TypeError as e: raise UnsafeExpressionError(比较运算失败) from e raise UnsafeExpressionError(f比较运算符不受支持: {type(op).__name__})这里有两个细节值得单独说明。第一个是布尔短路。and和or在Python里是有短路语义的比如False and (1 / 0 1)不会触发除零错误因为右边根本不会执行。上面的_bool_op是按顺序逐个求值的遇到能确定结果的地方就提前返回保持了Python原生语义。如果你写成“先把所有子表达式都求值再统一判真假”那False and (1 / 0 1)就会直接崩这算行为不一致排查起来非常迷惑。第二个是链式比较。Python里的a b c不是一个二元比较加一个与运算而是一个Compare节点ops和comparators是数组。处理时必须从左到右依次比较并且把中间值作为下一次比较的左值。上面的_compare方法就是按这个语义实现的。再来一个统一入口把解析、校验、求值、结果类型检查串起来def safe_eval(expression, env): if not isinstance(expression, str) or len(expression) MAX_EXPR_LEN: raise UnsafeExpressionError(表达式必须是字符串且长度受限) for key, value in env.items(): if not isinstance(value, (int, float, bool, str, type(None))): raise UnsafeExpressionError(f变量类型不允许: {key}) try: tree ast.parse(expression, modeeval) except SyntaxError: raise UnsafeExpressionError(表达式语法不正确) check_expression(tree) result SafeEvaluator(env).eval(tree.body) if not isinstance(result, (int, float, bool, str)): raise UnsafeExpressionError(结果类型必须是数值或布尔值) return resultenv就是业务传入的变量字典比如{amount: 100, rate: 0.05}。注意入口处对env的所有值也做了类型限制不允许任何复杂对象混进去。这一步很关键它保证表达式里能接触到的所有数据都是“安全数据”就算表达式里有、*这类操作也只是在数字、字符串之间操作不会触发任何对象方法链。4. 绕过与防御那些你以为安全、其实能被钻空子的AST细节4.1 危险节点清单照着背我把实际测试过、以及研究社区反馈后整理的“高危节点”列成了一张表。如果你的白名单里出现了这些节点请务必停下来想一想为什么。节点 / 操作符风险点默认策略Attribute通过x.__class__等访问对象内部属性是经典越狱第一步禁用Subscript配合Attribute可以遍历类列表、模块字典禁用Call函数调用如果没有白名单约束等于直接放行任意函数禁用需要时走函数白名单Lambda可隐藏任意逻辑生成可调用对象禁用ListComp/SetComp/DictComp推导式可制造大对象、嵌套调用禁用NamedExpr海象运算符允许在表达式中赋值污染变量环境禁用Pow运算符指数爆炸导致拒绝服务禁用必要时用受控函数替代ast.Starred*args式展开可能展开大迭代器反正其他节点也禁了顺手禁掉这个清单不是让你逐条背的核心记住一条原则白名单只放“叶子变量、数值运算、比较、布尔逻辑”这类纯粹的数据加工操作任何能产生新对象、访问属性、调用未知函数的结构都需要经过额外的白名单校验才能开放。4.2 不做点限制一个普通表达式也能打挂服务有些人觉得我已经禁了Call和Attribute应该天下太平了。但就算只有加减乘除攻击者还是能做出一些“脏活”。举几个我在测试阶段遇到的例子# 1. 大整数乘法导致CPU空转 999999999999999999999999999999 * 999999999999999999999999999999 # 2. 字符串乘法导致内存暴涨 a * 1000000000 # 3. 超长嵌套导致递归或解析开销 ((((((((((((((((((((1))))))))))))))))))))第一种和第二种靠节点数量限制和结果类型检查能兜住一部分但更稳妥的做法是在校验阶段限制常量长度和结果规模。我把字符串常量长度限制为20个字符运算结果如果出现超大字符串在入口处的类型检查里再补一道长度校验。第三种靠深度限制解决MAX_DEPTH 32足够覆盖正常公式也足够把恶意嵌套挡在外面。还有一个非常隐蔽的坑布尔值在Python里是int的子类。True True得到2True * 5得到5这种表达式“合法”但可能不符合业务预期。如果你不希望用户拿布尔值参与算术运算就得在类型检查里把bool和数值区分开如果你希望保留“条件表达式返回布尔”的能力那结果类型检查时要给布尔值单独放行。这两种策略各有利弊我在项目里选择的是“结果可以是数值或布尔”但所有运算都先正常执行再靠最终结果类型兜底。4.3 变量白名单不是摆设管理好传入环境很多人在校验器上做得很好却在env上翻车。比如把整个数据库对象、配置对象、甚至os模块直接塞进变量字典然后告诉用户“你可以引用env里的变量”。这么做等于自己把越狱钥匙递到攻击者手里。我的经验有三条。第一只传业务真正需要的变量。一个公式需要amount和rate就只传这两个不要图省事把整个上下文对象传进去。第二传值不传对象。入口处的类型检查已经强制所有值必须是int、float、bool、str、None从根本上杜绝了对象引用。第三如果变量是用户提交过来的一定要在存进env之前做好类型转换和合法性校验不要在表达式里做表达式里没有这个能力也不该有。4.4 错误信息也会泄露内部结构攻击者在试探一个表达式引擎时最依赖的就是错误信息。如果异常栈直接甩给用户他可以看到内部实现细节、模块路径、甚至服务端文件路径这对后续攻击非常有帮助。我的处理方式是定义专用的UnsafeExpressionError所有失败统一转成这个异常对外只返回一句“表达式不合法请检查语法或变量名”。内部日志里记录完整异常栈方便排查问题但日志内容要脱敏不能记录用户填写的整个表达式。为什么因为表达式本身可能包含业务敏感数据。5. 进阶如何安全地给表达式加上函数调用白名单5.1 为什么需要函数白名单纯四则运算和比较很多业务其实是够用的。直到有人提需求说“我想在公式里用sqrt开个方或者用round取个整。”这时候你已经不能把Call节点整个禁掉了需要一套更精细的规则允许调用但只允许调用白名单里的有限函数。5.2 函数白名单的校验设计在原来的节点白名单里增加ast.Call同时增加一个函数映射表ALLOWED_FUNCTIONS { abs: abs, round: round, min: min, max: max, floor: math.floor, ceil: math.ceil, sqrt: math.sqrt, }校验逻辑要加在check_expression的walk函数里if isinstance(node, ast.Call): if not isinstance(node.func, ast.Name): raise UnsafeExpressionError(只支持直接函数调用) if node.func.id not in ALLOWED_FUNCTIONS: raise UnsafeExpressionError(f函数不在白名单: {node.func.id}) if len(node.args) 4: raise UnsafeExpressionError(函数参数数量超出限制) if node.keywords: raise UnsafeExpressionError(不支持关键字参数)这里有几个细节非常容易踩坑。第一个是node.func必须是Name节点。很多绕过手法是让你调用“对象的方法”比如obj.method()、func()()它们的函数部分不是简单的Name节点而是Attribute节点或者Call节点。我们只允许函数名是白名单里的裸名字其余一律拦截。第二个是参数数量限制。就算min、max能接收任意多个参数但让用户一次传1000个参数本身就是一个风险点。4个以内足够覆盖绝大多数业务。第三个是禁止关键字参数。Python里很多函数的危险行为藏在关键字参数里统一禁掉能省掉一大块安全隐患。对应的求值器方法也很简单def _call(self, node): func_name node.func.id fn ALLOWED_FUNCTIONS.get(func_name) if fn is None: raise UnsafeExpressionError(f函数不在白名单: {func_name}) args [self.eval(arg_node) for arg_node in node.args] try: result fn(*args) except Exception as e: raise UnsafeExpressionError(函数执行失败) from e if not isinstance(result, (int, float, bool, str)): raise UnsafeExpressionError(函数返回类型不受支持) return result重点说一下“函数执行失败”的包装。直接用math.sqrt(-1)会抛ValueError直接让这个异常往外飞其实是危险信号因为攻击者可以通过不同异常类型探测内部实现细节。统一转成UnsafeExpressionError之后对外反馈完全不可区分。5.3 如果业务真的需要访问对象属性有些场景下需求方会说“我不想传散装的age、score变量想直接传一个user对象然后写user.age 18。”这是一个常见的诉求但也是一个高风险扩展。我的建议是不要真的把对象传进env而是用一个安全的包装类只暴露白名单里的字段。class SafeObject: def __init__(self, data): self.__dict__.update(data) def __getattribute__(self, name): if name.startswith(_) or name not in (age, score, amount): raise UnsafeExpressionError(f禁止访问属性: {name}) return object.__getattribute__(self, name)同时在ALLOWED_NODES里放开ast.Attribute并在校验函数中增加属性名的白名单检查if isinstance(node, ast.Attribute): if node.attr not in PASSED_ATTR_WHITELIST: raise UnsafeExpressionError(f属性不在白名单: {node.attr})但这是一个复杂度陡增的分支。你不仅要管属性名还要管Attribute链、对象的方法、对象背后的类型。我的项目最终没有开放这个能力而是让上层业务把对象拆成简单变量传进来。原因很简单宁可让产品经理多调用一下接口也不要在安全边界上留一个需要长期维护的口子。6. 集成到业务前的最后几点建议6.1 性能优化缓存编译结果而不是每次重新解析如果表达式引擎被频繁调用每次先解析成AST、再做完整校验、再解释执行性能确实会有点紧。一个非常有效的优化是把“解析校验编译”的结果缓存起来。from functools import lru_cache lru_cache(maxsize1000) def _compile_checked(expression): tree ast.parse(expression, modeeval) check_expression(tree) return compile(tree, expr, eval) def safe_eval_cached(expression, env): code _compile_checked(expression) for key, value in env.items(): if not isinstance(value, (int, float, bool, str, type(None))): raise UnsafeExpressionError(f变量类型不允许: {key}) return eval(code, {__builtins__: {}}, env)这里我把白名单变量集合写死在check_expression里所以同一个表达式可以被安全地复用。如果你的业务场景里不同调用者拥有不同的变量白名单那缓存的key必须把白名单版本一起算进去否则A用户的变量名可能被B用户复用这是一个隐蔽的逻辑漏洞。用编译后执行还有一个好处Python官方的编译器生成的字节码是高度优化的和我们自己写的解释器相比快得多。高频场景下我实测过带缓存的编译执行模式会比纯解释执行快一个数量级以上。6.2 测试用例怎么设计才有安全感我把安全相关的测试分成了三组你可以直接抄到自己的测试用例里。第一组是正常功能用例表达式期望结果说明amount * (1 rate)105基础四则运算amount 100 and rate 0.01True布尔与比较1 if score 60 else 01条件表达式score * 2 amount / 10130混合运算第二组是危险表达式用例每一行都必须被拒绝表达式拦截点__import__(os)Call节点不在白名单(1).__class__.__mro__Attribute节点被禁[x for x in range(10)]ListComp节点被禁lambda x: xLambda节点被禁x : 1NamedExpr节点被禁2 ** 100000Pow运算符被禁a * 1000000000结果类型或常量长度检查第三组是边界异常用例比如除零、未知变量、语法错误、超长表达式这些都应该抛出UnsafeExpressionError而不是原始异常。6.3 当你已经有了现成库还要不要自己写我在第一章说过很多现成沙箱库年久失修不适合直接上生产。但如果你需要的不是“表达式求值”而是完整的“受限Python执行环境”那自己从零写AST校验器的工作量会大得多这时候评估成熟的受限执行方案是更理性的选择。分清场景表达式求值小而精准自己写完整解释器重而复杂交给专业库并且要有能力维护它。最后说一点个人经验。做这个功能最难的其实不是写校验器本身而是每加一个新特性都要强迫自己问一句这个节点会不会让用户碰到不该碰的东西我在项目里坚持只开放最基础的算术和比较节点连幂运算都默认关闭上线几个月没有出过一次安全事故。如果你也在设计自己的表达式引擎我建议从最严格的版本开始再按业务需要一点点放权每开放一个节点类型就值得单独写一组攻击用例去验证它。安全这种东西永远是越少能力越安全而不是越多功能越好。