# 我们构建的工作流引擎，天生不可能出现 n8n 那种 CVE (1/5)

Source: https://lodos.md/zh/blog/workflow-engine-cant-have-n8n-cve-zh
Published: 2026-07-02 · Author: Samet Samyeli · Language: zh · Reading time: 2 min

n8n 曾因表达式 eval 而爆出一个 CVSS 9.9 分的 RCE 漏洞。lodos 的工作流引擎不可能出现这个 bug， 因为整条流水线里根本没有可供注入的求值器（evaluator），而一条构建期的 grep 检查确保它永远如此。

---

n8n 上个月被曝出 **CVE-2025-68613**，CVSS 评分 9.9，通过表达式 eval 实现的 RCE。但这还不是最有意思的部分。

真正有意思的地方在于：我为 lodos 设计的工作流引擎,从结构上就不存在这整类漏洞，不是因为我们的沙箱做得更好,也不是因为我们打补丁打得更快,而是因为整条流水线里根本没有任何表达式求值器可供注入。构建脚本会对此做 grep 检查。如果未来的我在工作流的接口上加了 `eval`、`new Function` 或 `vm.runInContext`,构建会在提交落地之前就变红。

这篇文章是一次架构导览:"声明式受限"(declarative-bounded)在 YAML schema 层面究竟意味着什么、我为什么故意放弃图灵完备性、以及那条守住底线的具体 grep 是什么样的。

如果你在构建工作流工具，或者任何一个会接收用户提供的表达式并执行它们的系统，这笔权衡值得诚实地审视一番。n8n 做出了不同的选择,他们也因此付出了一个 CVSS 9.9 的 RCE。我做出了这个选择,我得到的是一个表达能力严格弱于他们的工作流引擎。请清醒地选择你自己的权衡。

## "声明式"在这里究竟意味着什么

lodos 的工作流引擎只暴露五个原语(primitive),仅此而已:

- `http_request`：method、URL 模板、headers、body、`allowedEgress` 主机列表、`timeoutMs`
- `db_query`：只能对本地 SQLite 执行 SELECT,并对 vault/billing 相关表设有静态拒绝列表(denylist)
- `ai_call`：硬编码指向 `api.anthropic.com` 的出站访问,`noSecrets: true`(发送前会剥离 transcript)
- `tool_call`：只读自动(read-only-auto)的 MCP 工具,受 guard 门控,支持可选降级(optional-degrade)
- 控制流：`wait`、`if_else`、`loop`,搭配**封闭枚举比较符**(`eq`、`neq`、`gt`、`lt`、`changed-since-last-run`)

最后一条是我最想为之辩护的。我见过的大多数工作流引擎，n8n、Zapier、Temporal，甚至允许你在条件槽位里直接写一段 JS 表达式。*如果这个数字大于 5,就走左边分支。* 表达式求值器对用户很方便,对安全团队却是一道悬崖。所以我们干脆不提供它。比较符是一个枚举值;比较的对象是数据;不存在任何一条路径能让用户编辑的 YAML 字段直接变成一次函数调用。

这样做是有代价的。你没法写 `if step.result.users.filter(u => u.active).length > 5`。但你可以写 `if step.result.activeUserCount gt 5`,然后在上游的 `db_query` 或 `ai_call` 步骤里生成 `activeUserCount`。计算被移到了那些已经存在的原语里;工作流定义本身则始终保持声明式。

![n8n 基于表达式的条件判断会把用户输入编译成可执行代码;lodos 的声明式比较符则始终让用户输入保持为数据,而非代码。](/blog/workflow-engine-cant-have-n8n-cve/2a.webp)

## Schema 层面的纪律

工作流 YAML 会通过一个使用 `z.lazy` 实现递归的 Zod schema 来解析(loop 和 `if_else` 可以嵌套)。不加限制的 `z.lazy` 本质上就是 eval 的另一个名字，一份恶意构造的 YAML 可以在任何 handler 运行之前就把栈或堆打爆。所以这个 schema 是双重受限的:

```typescript
const StepSchema: z.ZodType<Step> = z.lazy(() =>
  z.discriminatedUnion('type', [
    HttpRequestStepSchema,
    DbQueryStepSchema,
    AiCallStepSchema,
    ToolCallStepSchema,
    WaitStepSchema,
    IfElseStepSchema, // contains: steps[] (z.lazy, max 16)
    LoopStepSchema,   // contains: body[]  (z.lazy, max 16)
  ])
);

// Walk-time enforced in addition to per-array caps:
const MAX_TOTAL_NODES = 100;
const MAX_DEPTH = 5;
```

![lodos 工作流引擎:声明式 YAML 受 Zod schema 约束，深度 ≤ 5、总节点数 ≤ 100、每个数组的子项 ≤ 16 个，覆盖五个原语,不含 eval、不含 Function、不含 vm。](/blog/workflow-engine-cant-have-n8n-cve/3a.webp)

三条边界,每一条都是必要的。`per-array .max(16)` 防止任何单个代码块膨胀成一整段嵌套分支组成的"大文件"。`total-nodes 100` 约束整张图(你没法通过把复杂度铺薄摊开来绕过单个数组的上限)。`depth 5` 让这次递归遍历的耗时相对输入保持常数级。这三条限制没有一条是过度谨慎，每一条都封死了一类真正的工作流用户绝不会写出来、但恶意 YAML 会写出来的资源攻击。

## 为什么不存在 `secret_value_get` 这样的工具

系统里不存在任何一个 MCP 工具,其契约是"把某个 secret 的明文给我"。我们有的是一个六层防护的 `secret_inject_and_run`，它接收一个 secret 引用和一个 argv,把值设置进子进程的环境变量,运行命令,然后**绝不**把这个值返回给发起调用的 AI。仅此而已。

这不是一条"策略"(不是那种"我们不加这类工具"的口头约定)。构建脚本会对它的**不存在**本身做出断言:

```javascript
// scripts/verify-moat-invariants.mjs - INV-M1 (simplified)
const FORBIDDEN_TOOL_NAMES = [
  /\bbash\b/,
  /\bexec\b/,
  /\bshell\b/,
  /_value_get$/,    // catches secret_value_get, dek_value_get, anything _value_get
  /^secret_value_/,
];

const mcpIndexSrc = readFileSync('apps/mcp/src/index.ts', 'utf8');
for (const pattern of FORBIDDEN_TOOL_NAMES) {
  if (pattern.test(mcpIndexSrc)) {
    console.error(`INV-M1 VIOLATION: forbidden tool pattern ${pattern}`);
    process.exit(1);
  }
}
```

如果未来的我，或者未来某个在这个代码库上工作的 AI 编辑器，说服自己需要一个会返回明文值的 secret 工具,构建会在合并之前就拒绝它。

## INV-M5：eval 绊线检测

针对工作流引擎的对应检查甚至更简单:

```javascript
// INV-M5: no eval-class primitive in workflow / skill / deck surfaces
const FORBIDDEN_EXEC_PATTERNS = [
  /\beval\s*\(/,
  /\bnew\s+Function\s*\(/,
  /\bvm\.(runIn|compileFunction)/,
  /\brequire\(['"]child_process['"]\)/,
  /\bimport\s+.*\bfrom\s+['"]child_process['"]/,
];

for (const dir of ['electron/workflow/', 'electron/skills/', 'electron/deck/']) {
  for (const file of walkTs(dir)) {
    const src = readFileSync(file, 'utf8');
    for (const pattern of FORBIDDEN_EXEC_PATTERNS) {
      if (pattern.test(src)) {
        console.error(`INV-M5 VIOLATION in ${file}: ${pattern}`);
        process.exit(1);
      }
    }
  }
}
```

五十行 Node 代码,零依赖,跑在标准的 `pnpm verify` 步骤里。n8n 那个 CVSS 9.9 表达式 RCE 所属的整整一类 CVE,就这样被一条 grep 检查关上了门。

真正让这条检查有意义的,是**反证(negative proof)**这道纪律:这个脚本会附带一份它自己的伪造违规样本，一个一次性的 `__probe.ts` 文件,里面写着一段字面量的 `eval(`。CI 会把这个脚本跑两遍:第一遍注入这个探针(必须以退出码 1 结束),第二遍移除它(必须以退出码 0 结束)。一个永远不会失败的"拒绝检测器",和根本没有检测器没有区别;正是这道反证,才让这个脚本真正长出了牙齿。

## 通过收窄而非放松来实现功能增长

一个理性的怀疑:等你需要更多功能的时候,这套设计不就会崩掉吗?

当我为 L2 级别的 web 出站访问上线 `web_fetch` 时,我没有加一个求值器。我加的是:窄域名白名单 + 按来源的字节预算 + 重定向重新鉴权 + 防自我 SSRF + 渲染防火墙 + 受污染内容的传播追踪。新增了二十六个测试用例,新增的 eval 接口数量为零。这个引擎变得更强大,靠的是增加**边界**,而不是增加**表达式能力**。

这条路是走得通的。工作流工具里的大多数"功能增长",默认被理解成"给用户更多的 eval 接口"。但它完全可以被理解成另一种方式:"给用户更多受限的原语"。后者设计起来更难,但它永远不可能成长出一个 CVSS 9.9。

## 这付出了什么代价,我为什么愿意付

你放弃的是:任意的单步内计算、JS 表达式条件判断、动态字段投影，也就是用户在 n8n 里写 `{{ $json.foo.map(x => x * 2) }}` 时能做的那些事。真实的工作流用户确实会需要这些能力,而在 lodos 里,他们获得这些能力的方式是多写一个 `ai_call` 或 `db_query` 步骤。工作流定义始终保持声明式;eval 接口始终保持空白。

你换来的是:一个从结构上就不可能出现 CVE-2025-68613 这类漏洞的工作流引擎,一个由 schema 强制约束资源消耗上限的系统,以及一套每一次"拒绝"都由一条 grep 检查、而非一段说明文档来强制执行的机制。

---

系列的下一篇文章会再往下一层,深入 vault 内部。*"AI sk_live'ı göremez"*，AI 看不见你的 Stripe live key，是这套架构提出的核心主张,而支撑它的那段 schema 代码,是我最引以为傲的部分。**《零知识即架构性失明》**这篇文章会解释,为什么我们的 Prisma schema 从字面上就不可能容纳一个明文字段路径,以及这一点如何让我们免费获得了 SOC2 级别的审计能力。
