security(expr): $F.mul/extend 等容器/字符串多态算子无界放大——29 字节表达式分配 1GB(@flow 源码 a*b 即编译成 $F.mul),$F.pow 的 DoS 守卫未推广
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 50/100
Research direction
Start with _fn_pow and its guard in plaita/core/expression.py, then _fn_mul, _fn_extend and _fn_join in the same file, and read the default registry where they are registered. Add a size check to _fn_mul for string and list repetition, and to the concatenation functions, raising ValueError like the pow guard. Run tests/unit/test_user_review_fixes.py, including test_pow_exponent_capped. Done when _fn_mul('A', 1000000000) raises ValueError, small results such as _fn_mul('ab', 3) are unchanged, and the existing tests still pass.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 65f48f2
Summary
表达式引擎的数学/容器算子对「小输入、巨大输出」的多态放大没有任何规模守卫。$F.pow 在
37c2c62 已被加上指数上限(同批安全评审 P1),但 mul / add / extend / append /
join 等同属无界放大的算子完全没有同类保护,求值侧也不做返回值规模探测。
plaita/core/expression.py:161-165——add/sub/mul/div/mod直通 Python 算子,
无任何规模检查;注意mul同时是多态算子:字符串重复、列表重复、数值乘法都走它:
def _fn_add(a, b): return a + b
def _fn_sub(a, b): return a - b
def _fn_mul(a, b): return a * b
def _fn_div(a, b): return a / b
def _fn_mod(a, b): return a % b
plaita/core/expression.py:166-180—— 唯一的守卫只覆盖pow,且注释明写这里的风险模型
(「约 20 字节的表达式曾可烧掉分钟级 CPU 并膨胀数十 MB 内存」),证明同类风险已被承认:
_POW_MAX_EXPONENT = 10_000
def _fn_pow(a, b):
"""幂运算,指数设上限(2026-09 安全评审 P1)。
``$F.pow(9, 9999999)`` 这种约 20 字节的表达式曾可烧掉分钟级 CPU 并膨胀
数十 MB 内存——单流程即可拖死 worker,并行分支还能线性放大。
"""
if isinstance(b, int) and abs(b) > _POW_MAX_EXPONENT:
raise ValueError(
f"$F.pow exponent too large: {b} (limit {_POW_MAX_EXPONENT}). "
"Refusing to burn CPU on an unbounded power computation."
)
return a ** b
plaita/core/expression.py:221-222—— 容器侧同类算子同样裸奔:
def _fn_append(a, b): return a + [b]
def _fn_extend(a, b): return a + b
plaita/core/expression.py:285与:337—— 两者都进默认注册表,对任意 flow 的任意表达式可见:
("mul", _fn_mul, "Multiply two values"),
...
("extend", _fn_extend, "Extend list (returns new list)"),
plaita/core/expression_parser.py:436-460—— 求值侧直接func(*args),不探测参数规模也不校验
返回值规模,所以放大发生在 Python 字节码内部:
def thunk(frame: Frame) -> Any:
_context, registry, _prefix = frame
args = [arg(frame) for arg in arg_thunks]
logger.debug("parse_function: func_name=%s, args=%s", func_name, args)
func = _lookup_function(registry, func_name)
...
return func(*args)
plaita/dsl/codeflow/_expr.py:36-43—— 这不是「只能手写表达式才踩」的问题:@flow源码里
任意二元*都被编译成$F.mul(...)(_BINOP_TO_F在plaita/dsl/codeflow/_common.py:92-95
把ast.Mult映射为mul):
if isinstance(node, ast.BinOp):
fname = _BINOP_TO_F.get(type(node.op))
if fname is None:
raise _CodeflowError(f"不支持的二元运算 {type(node.op).__name__}", node)
left = _compile_expr(node.left, ctx)
right = _compile_expr(node.right, ctx)
return f"$F.{fname}({_render_arg(left)}, {_render_arg(right)})"
复现(实跑,/tmp/plaita-audit-venv/bin/python,Python 3.12,plaita editable 安装):
$ cd /tmp && /tmp/plaita-audit-venv/bin/python - <<'EOF'
import resource, time
from plaita.core.expression_parser import ExpressionParser
p = ExpressionParser.for_prefix('$')
ctx = {'$INPUT': {'lst': [0]}}
def rss(): return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024/1024
e = "{% $F.mul('A', 1000000000) %}"
print('expr len:', len(e))
t=time.time(); r=p.evaluate(e, ctx)
print('1e9 str len', len(r), 'secs %.3f'%(time.time()-t), 'rss MB %.1f'%rss())
t=time.time(); r2=p.evaluate('{% $F.mul($INPUT.lst, 100000000) %}', ctx)
print('list x1e8 len', len(r2), 'secs %.3f'%(time.time()-t), 'rss MB %.1f'%rss())
try: p.evaluate('{% $F.pow(9, 9999999) %}', ctx)
except ValueError as ex: print('pow guard:', ex)
EOF
--- 输出 ---
expr len: 29
1e9 str len 1000000000 secs 0.060 rss MB 1035.6
list x1e8 len 300000000 secs 1.977 rss MB 2084.7
pow guard: $F.pow exponent too large: 9999999 (limit 10000). Refusing to burn CPU on an unbounded power computation.
29 字节的表达式在 0.06 秒内产出 10 亿字符、RSS 冲到约 1GB;把 $INPUT.lst(长度 1 的单元素
列表)乘 1e8 得到长度 3 亿的列表、RSS 约 2GB。同一进程里 $F.pow(9, 9999999) 确实被守卫拦住,
即守卫只覆盖 pow 是确认过的现状,不是推测。
@flow 源码侧的可达性(实跑):
$ cd /Users/jeff.jie/projects/infra4agent/plaita && /tmp/plaita-audit-venv/bin/python -c \
"from plaita.dsl.codeflow import compile_source; ir=compile_source('from plaita.dsl.codeflow import flow\n@flow(\"t\")\ndef t(INPUT):\n big = \"A\" * 1000000000\n return big\n'); print([n.get('output') for n in ir['nodes'] if n.get('type')=='assignment'])"
--- 输出 ---
['$F.mul("A", 1000000000)']
即普通 @flow 写法 big = "A" * 1000000000 编出来的 IR 就是 $F.mul("A", 1000000000),
不需要作者手写 {% ... %} 模板。
反证:现有单测只有 tests/unit/test_user_review_fixes.py:681-686 覆盖 pow 上限,没有任何
mul / extend / 容器拼接的规模断言:
def test_pow_exponent_capped(self):
"""$F.pow 指数上限:20 字节表达式不再能烧分钟级 CPU。"""
from plaita.core.expression import _fn_pow
with self.assertRaises(ValueError):
_fn_pow(9, 9999999)
self.assertEqual(_fn_pow(2, 10), 1024)
影响
- 谁踩到:任何不被完全信任的流程作者或 IR 生成方。
plaita的定位是 AI/LLM 生成流程并发布
执行(console 发布、MCP 生成 IR、@flow源码),表达式与赋值节点是无处不在的作者可控输入;
worker 是长驻进程,一次求值就吃掉 worker 的可用内存。 - 条件与代价:内存放大在表达式求值内部发生,早于任何 I/O 与任何可中断点。因此 flow 级
timeout、HTTP 节点响应体上限、以及沙箱子进程的RLIMIT_AS都拦不到这条路径——被拖死的是
worker/console 自身的 Python 进程。29 字节写 1GB、再加几个参数即可线性推到 2GB 以上;并行分支
各自求值会叠加。代价是 OOM / RSS 膨胀到宿主无法回收,同 worker 上的其他执行一起受害。 - 生产可达:常见形态即可达——流程作者在
assignment/节点参数里写$F.mul('A', 1000000000),
或直接写big = "A" * 1000000000并被编译成同一条表达式;无需特殊部署档位。 - 不修的后果:
$F.pow当年触发的「单流程拖死 worker」现在原样保留在同族算子上,安全评审修掉的
只是一个实例而不是这一类风险。
建议
- 修复方向:在
plaita/core/expression.py里把pow的守卫泛化成一族规模守卫,与_fn_pow
同形(同样的ValueError文案风格,便于作者定位):_fn_mul(字符串/列表重复分支):在调用前探测len(a) * b(b为 int 时)或
len(a) * len(b)的有效结果规模;超过阈值直接raise ValueError。_fn_add/_fn_extend/_fn_append/_fn_join:对序列/字符串参数探测len(a) + len(b)
(或 join 的 Σ len)是否越界。- 阈值可以复用
_POW_MAX_EXPONENT的思路抽出模块级常量(例如元素数/字符数上限),
使「改一处即可调参」,避免散落魔法数。
- 最小修法(一处):在
_fn_mul加与_fn_pow同形的守卫即可掐掉'A' * 1e9这一条最廉价的
放大路径(29 字节 → 1GB);容器侧extend/join紧随其后补齐,不必一次性重构求值器。 - 验收条件(可执行、可断言):
- 新增单测(可放在
tests/unit/test_user_review_fixes.py与test_pow_exponent_capped相邻):
断言_fn_mul("A", 1000000000)、_fn_mul([0], 100000000)、_fn_extend([0] * 10, [0] * 100000000)
均抛ValueError;并保留正向用例断言小规模结果不变
(_fn_mul("ab", 3) == "ababab"、_fn_extend([1], [2]) == [1, 2])。 - 复现脚本应变为:
p.evaluate("{% $F.mul('A', 1000000000) %}", ctx)抛ValueError
(文案含上限值),而不是返回长度 10 亿的字符串;rss MB不再出现 GB 级跳变。 - 回归:
$F.pow现有守卫与test_pow_exponent_capped必须继续通过;$F.mul的数值用例
(如tests/unit/test_codeflow_source.py:47、tests/unit/test_dsl.py:102使用的
F.mul(INPUT.item, 2)形状)不得被新守卫误伤。
- 新增单测(可放在
边界
- 本单只处理表达式引擎内建算子的无界资源放大(
mul及其同族的字符串/列表多态,以及
add/extend/append/join的拼接放大)。不改$F.div/$F.mod的零除语义,
不改表达式的解析/编译架构,也不引入通用配额系统。 - 与相邻 issue 的差异(查重结论):
gh issue list -R jeffkit/plaita --state all --limit 100
共 33 条,#31(HTTP 节点响应体无大小上限)与#41(已关闭:沙箱RLIMIT_AS在 Linux 强制生效)
都不覆盖表达式求值器内部的多态放大——#31是节点 I/O 层、#41是子进程层,本单的分配发生在
worker 自身进程的 Python 字节码里,三者的修复面互不重叠,故非重复。 - 不顺手扩大范围:不要求同时修
$F.pow已修部分的文案或阈值;若后续要做「求值器统一资源预算」
(如按返回值规模统一拦截),那属于更大的设计变更,应另开单,本单只要求把已有守卫推广到同族算子。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 3h
- Merged PRs (30d)
- 6
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Similar issues
-
documentation
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
good first issue module-baseline needs triage ux
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
darnitdevorg/darnit#650 ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
nyjc-computing/campus#938 ·
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
nf-osi/nf-metadata-dictionary#1027 ·
Maintainers usually reply within 1 day
-
bug documentation
Difficulty 1/5 Under an hour Newbie friendliness 92/100
qatrackplus/qatrackplus#913 ·
Maintainers usually reply within 3 days