AST 安全求值

AST 安全求值 指的是:把表达式/代码先解析成抽象语法树(AST),然后不直接 eval / compile 执行,而是自己遍历 AST,只允许白名单内的节点,并按预定语义解释执行。核心目标是避免任意代码执行、沙箱逃逸和拒绝服务。

一句话:解析成 AST + 白名单解释器 + 资源限制 + 默认拒绝。


1. 为什么不直接用 eval?

Python 的 eval、JS 的 eval / new Function 都极其危险:

python

复制代码
eval("__import__('os').system('rm -rf /')")

即使限制 __builtins__,也可能通过属性链、类继承、生成器等方式逃逸。

所以安全求值不能依赖"过滤字符串",而应基于 AST 白名单。


2. 安全求值的基本原则

  1. 默认拒绝:没显式允许的 AST 节点一律报错。

  2. 不调用、不导入、不访问属性 :禁止 Call、Import、Attribute、Subscript 等。

  3. 变量来自受控环境 :只允许简单类型,如 int/float/str/bool/None。

  4. 运算符白名单 :只允许 + - * / // % **、比较、布尔等,并限制 **。

  5. 资源限制:输入长度、AST 节点数、递归深度、数值大小、字符串长度、执行时间。

  6. 纯函数、无副作用:求值不能修改外部状态。


3. Python 示例:安全表达式求值

只允许变量、常量、四则运算、比较、布尔、三元表达式。

python

复制代码
import ast
import operator as op

_BIN = {
    ast.Add: op.add,
    ast.Sub: op.sub,
    ast.Mult: op.mul,
    ast.Div: op.truediv,
    ast.FloorDiv: op.floordiv,
    ast.Mod: op.mod,
}

_UNARY = {
    ast.UAdd: op.pos,
    ast.USub: op.neg,
    ast.Not: op.not_,
}

_CMP = {
    ast.Eq: op.eq, ast.NotEq: op.ne,
    ast.Lt: op.lt, ast.LtE: op.le,
    ast.Gt: op.gt, ast.GtE: op.ge,
    ast.In: lambda a, b: a in b,
    ast.NotIn: lambda a, b: a not in b,
}

_ALLOWED_TYPES = (int, float, str, bool, type(None))

def safe_eval(expr: str, variables=None, max_nodes=1000, max_pow=100):
    variables = variables or {}
    tree = ast.parse(expr, mode="eval")

    if sum(1 for _ in ast.walk(tree)) > max_nodes:
        raise ValueError("表达式过大")

    return _eval(tree, variables, max_pow)

def _eval(node, env, max_pow):
    if isinstance(node, ast.Expression):
        return _eval(node.body, env, max_pow)

    if isinstance(node, ast.Constant):
        if isinstance(node.value, _ALLOWED_TYPES):
            return node.value
        raise ValueError("不允许的常量")

    if isinstance(node, ast.Name):
        if node.id in env and isinstance(env[node.id], _ALLOWED_TYPES):
            return env[node.id]
        raise ValueError(f"未知或不允许的变量: {node.id}")

    if isinstance(node, ast.BinOp):
        left = _eval(node.left, env, max_pow)
        right = _eval(node.right, env, max_pow)

        if isinstance(node.op, ast.Pow):
            if not isinstance(right, (int, float)) or abs(right) > max_pow:
                raise ValueError("指数过大")
            if isinstance(left, (int, float)) and abs(left) > 1e6:
                raise ValueError("底数过大")
            return left ** right

        fn = _BIN.get(type(node.op))
        if fn is None:
            raise ValueError("不允许的二元运算")

        # 防止字符串乘法耗尽内存
        if isinstance(node.op, ast.Mult):
            if isinstance(left, str) and isinstance(right, int) and len(left) * right > 10000:
                raise ValueError("字符串过长")
            if isinstance(right, str) and isinstance(left, int) and len(right) * left > 10000:
                raise ValueError("字符串过长")

        return fn(left, right)

    if isinstance(node, ast.UnaryOp):
        fn = _UNARY.get(type(node.op))
        if fn is None:
            raise ValueError("不允许的一元运算")
        return fn(_eval(node.operand, env, max_pow))

    if isinstance(node, ast.BoolOp):
        if isinstance(node.op, ast.And):
            result = True
            for v in node.values:
                result = _eval(v, env, max_pow)
                if not result:
                    return result
            return result
        if isinstance(node.op, ast.Or):
            result = False
            for v in node.values:
                result = _eval(v, env, max_pow)
                if result:
                    return result
            return result
        raise ValueError("不允许的布尔运算")

    if isinstance(node, ast.Compare):
        left = _eval(node.left, env, max_pow)
        for op_node, comparator in zip(node.ops, node.comparators):
            right = _eval(comparator, env, max_pow)
            fn = _CMP.get(type(op_node))
            if fn is None or not fn(left, right):
                return False
            left = right
        return True

    if isinstance(node, ast.IfExp):
        cond = _eval(node.test, env, max_pow)
        return _eval(node.body if cond else node.orelse, env, max_pow)

    raise ValueError(f"不允许的语法: {type(node).__name__}")

使用:

python

复制代码
safe_eval("a + 2 * (b - 1)", {"a": 3, "b": 5})
# 11

被禁止的语法会直接报错:

python

复制代码
safe_eval("__import__('os').system('ls')")
# ValueError: 不允许的语法: Call

4. 如果只处理字面量

Python 自带 ast.literal_eval:

python

复制代码
import ast

ast.literal_eval("{'a': [1, 2, 3]}")

它只允许字面量,安全边界较窄,但不能计算变量、表达式。


5. JS 中的思路

不要用:

js

复制代码
eval(expr)
new Function(expr)

应使用 acorn、espree 等解析成 ESTree,然后白名单解释:

  • 允许:Literal、Identifier、BinaryExpression、LogicalExpression、UnaryExpression、ConditionalExpression

  • 禁止:CallExpression、MemberExpression、AssignmentExpression、NewExpression、FunctionExpression、ArrowFunctionExpression、TemplateLiteral、ImportExpression、ThisExpression


6. 关键限制与结论

AST 安全求值不是"绝对安全",只是大幅缩小攻击面。生产环境还应:

  • 限制表达式长度、AST 节点数、执行时间、内存;

  • 禁止 Call、Attribute、Subscript、Import、Lambda、推导式、f-string;

  • 不把模块、函数、对象、类实例传入变量环境;

  • 必要时放到独立进程、容器、WASM 或 seccomp 沙箱中执行。

结论:

AST 安全求值 = 解析 + 白名单遍历 + 纯解释器 + 资源限制 + 默认拒绝。

复杂场景建议使用专用表达式引擎,如 CEL、JsonLogic、simpleeval、asteval 等,而不是自己从零实现。

相关推荐
我们从未走散1 小时前
AI 生图平台如何避免资损:用“扣款与凭据同事务”构建三态账本
开发语言·数据库·php
weixin_440730502 小时前
字符编码笔记-utf-8 、unicode、gbk,ascii编码格式
开发语言·笔记
Java_2017_csdn2 小时前
OFC、ERP、OMS、WMS、TMS 对比-详解
java
励志不掉头发的内向程序员3 小时前
【从零写一个CAD 03】三个 double 值得单独一个类吗:把视图变换抽成 View
开发语言·c++·qt·学习·系统架构
PHP实战开发录3 小时前
PHP接口大整数为什么变了
开发语言·php
临床数据科学和人工智能兴趣组6 小时前
在R语言中, 使用 as.factor() 函数转换数值型变量
开发语言·r语言
andyweike9 小时前
php笔记
开发语言·笔记·php
EatFan10 小时前
Spring Boot 4 落地观察:从 yudao-cloud、matecloud、JPower 看国产脚手架的升级路线与迁移清单
java·spring boot·后端·spring cloud·微服务·后端开发·jdk 21
Maiko Star10 小时前
* LangChain 提示词模板详解:ChatPromptTemplate 的使用与高级特性
java·人工智能·langchain