workflow-engine

Мы создали workflow-движок, в котором не может быть CVE от n8n (1/5)

В n8n была RCE с CVSS 9.9 через eval выражений. В workflow-движке lodos такой баг невозможен в принципе, там просто нет вычислителя, в который можно было бы что-то внедрить, а grep во время сборки следит, чтобы так и оставалось.

6 мин чтения

В прошлом месяце в n8n нашли CVE-2025-68613, CVSS 9.9, RCE через eval выражений. Но это не самое интересное.

Интересно то, что весь класс этой уязвимости структурно отсутствует в workflow-движке, который я спроектировал для lodos, не потому что мы лучше песочницу настроили, не потому что быстрее патчим, а потому что во всём пайплайне нет ни одного вычислителя выражений, в который можно было бы что-то внедрить. Сборочный скрипт грепает его наличие. Если будущий я добавит eval, new Function или vm.runInContext на поверхность workflow-движка, сборка станет красной ещё до того, как коммит попадёт в репозиторий.

Этот пост, архитектурный разбор: что на самом деле означает «декларативно-ограниченный» на уровне схемы YAML, почему я осознанно отказался от Тьюринг-полноты, и какой именно grep удерживает эту границу.

Если вы разрабатываете инструменты для workflow, или вообще любую систему, которая принимает пользовательские выражения и исполняет их,, этот компромисс стоит рассмотреть честно. n8n выбрали другой путь, и получили за это RCE с CVSS 9.9. Я выбрал этот, и в итоге у меня есть workflow-движок, который строго менее выразителен, чем у них. Выбирайте свой компромисс осознанно.

Что здесь на самом деле значит «декларативный»

Workflow-движок lodos предоставляет пять примитивов. И только их:

  • http_request: метод, шаблон URL, заголовки, тело, список хостов allowedEgress, timeoutMs
  • db_query: только SELECT к локальной SQLite, со статическим deny-листом для таблиц vault/billing
  • ai_call: жёстко зашитый egress на api.anthropic.com, noSecrets: true (транскрипт очищается перед отправкой)
  • tool_call: только read-only-auto MCP-инструменты, под контролем guard'а, с поддержкой опционального деградирования
  • Управление потоком: wait, if_else, loop с закрытым перечислением компараторов (eq, neq, gt, lt, changed-since-last-run)

Именно последнюю строчку я и хочу защитить. Большинство виденных мной workflow-движков, n8n, Zapier, Temporal, позволяют вписать JS-выражение прямо в слот условия. Если это число больше 5, идём налево. Вычислитель выражений удобен для пользователей и является обрывом для команд безопасности. Поэтому у нас его нет. Компаратор, это enum; сравниваемое значение, это данные; пути от отредактированного пользователем поля YAML до вызова функции просто не существует.

Что-то при этом теряется. Нельзя написать if step.result.users.filter(u => u.active).length > 5. Зато можно написать if step.result.activeUserCount gt 5 и получить activeUserCount заранее, на шаге db_query или ai_call. Вычисления переносятся на уже существующие примитивы; определение workflow остаётся декларативным.

Условия в n8n на основе выражений компилируют пользовательский ввод в исполняемый код; декларативные компараторы lodos оставляют пользовательский ввод данными, а не кодом.

Дисциплина схемы

YAML workflow парсится через Zod-схему с z.lazy для рекурсии (циклы и if_else могут быть вложены). Неограниченный z.lazy, это, по сути, другое имя для eval: злонамеренный YAML может обвалить стек или кучу ещё до того, как отработает хоть один обработчик. Поэтому схема ограничена дважды:

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;

Workflow-движок lodos: декларативный YAML, ограниченный Zod-схемой, глубина ≤ 5, всего узлов ≤ 100, ≤ 16 дочерних элементов на массив, поверх пяти примитивов, без eval, без Function, без vm.

Три ограничения, и каждое из них необходимо. per-array .max(16) не даёт ни одному блоку превратиться в мегабайт вложенных веток. total-nodes 100 ограничивает весь граф целиком (нельзя протащить сложность мимо лимита на массив, размазав её тонким слоем). depth 5 удерживает рекурсивный обход в константном времени относительно входных данных. Ни одно из этих трёх ограничений не паранойя, каждое закрывает целый класс атак на ресурсы, которые реальный пользователь workflow никогда бы не написал, а вот враждебный YAML, вполне.

Почему secret_value_get не существует

В системе нет ни одного MCP-инструмента, чей контракт звучал бы как дай мне значение секрета в открытом виде. Есть шестислойный secret_inject_and_run, который принимает ссылку на секрет и argv, устанавливает значение в переменные окружения подпроцесса, запускает команду и никогда не возвращает значение вызывающему AI. Вот и всё.

Это не политика («не добавляй такой инструмент»). Сборочный скрипт проверяет само отсутствие:

// 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-редактор, работающий с этой кодовой базой, убедит себя, что нужен инструмент, возвращающий значение секрета, сборка откажет ещё до мержа.

INV-M5: растяжка на eval

Соответствующая проверка для workflow-движка ещё проще:

// 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. Весь класс CVE, к которому относится RCE-баг n8n через выражения с CVSS 9.9, закрывается одним grep'ом.

Дисциплина, которая делает это по-настоящему рабочим,, это негативное доказательство: скрипт поставляется вместе с собственным фейковым нарушением. Одноразовый файл __probe.ts с буквальным eval( внутри. CI запускает скрипт дважды, сначала с внедрённым probe'ом (обязан завершиться с кодом 1), затем без него (обязан завершиться с кодом 0). Детектор отказов, который никогда не срабатывает, неотличим от полного отсутствия детектора; именно негативное доказательство даёт этому скрипту зубы.

Рост функциональности через сужение, а не через послабление

Скептический вопрос читателя: а не ломается ли всё это, когда нужны новые возможности?

Когда я выпустил web_fetch для L2 web-egress, я не добавил вычислитель. Я добавил: узкий allowlist хостов + бюджет байт на каждый источник + повторную аутентификацию при редиректах + защиту от SSRF-против-себя + render-firewall + распространение статуса «заражённого» контента. Двадцать шесть новых тестов, ноль новой поверхности для eval. Движок стал мощнее за счёт добавления ограничений, а не за счёт добавления силы выражений.

Так можно делать. Большая часть роста инструментов для workflow трактуется как «дать пользователю больше поверхности для eval». Вместо этого это можно трактовать как «дать пользователю больше ограниченных примитивов». Второе сложнее проектировать, но невозможно вырастить до CVSS 9.9.

Чего это стоит и почему я на это пошёл

Вы теряете: произвольные вычисления внутри шага, условия на JS-выражениях, динамическую проекцию полей, всё то, что пользователь может сделать в n8n через {{ $json.foo.map(x => x * 2) }}. Реальные пользователи workflow действительно тянутся к таким вещам, и в lodos, когда это происходит, они получают их, дописав ещё один шаг ai_call или db_query. Определение workflow остаётся декларативным; поверхность для eval остаётся пустой.

Вы получаете: workflow-движок, в котором CVE-2025-68613 структурно невозможен, где схема принудительно ограничивает потребление ресурсов, и где каждый отказ обеспечивается grep'ом, а не строчкой в документации.


Следующий пост в серии спускается на уровень ниже, в vault. "AI sk_live'ı göremez", AI не может увидеть ваш боевой ключ Stripe, вот архитектурное утверждение, и фрагмент схемы, который его подтверждает,, то, чем я горжусь больше всего. Zero-Knowledge как архитектурная слепота объясняет, почему наша Prisma-схема буквально не может содержать поле с путём к секрету в открытом виде, и как это даёт нам историю аудита уровня SOC2 бесплатно.

workflow-engineбезопасностьarchitecturezero-knowledge

Запусти свою компанию на собственной машине.

Записывайся в лист ожидания и получи доступ первым, когда lodos откроется.