workflow-engine
Zbudowaliśmy silnik workflow, który nie może mieć CVE n8n (1/5)
n8n wypuściło błąd RCE o CVSS 9.9 poprzez ewaluację wyrażeń. Silnik workflow lodos nie może mieć takiego błędu, nigdzie nie ma ewaluatora, do którego dałoby się coś wstrzyknąć, a grep uruchamiany podczas builda dba o to, by tak pozostało.
n8n miało w zeszłym miesiącu CVE-2025-68613, CVSS 9.9, RCE poprzez ewaluację wyrażeń. To nie jest ciekawa część.
Ciekawa część polega na tym, że cała ta klasa podatności jest strukturalnie nieobecna w silniku workflow, który zaprojektowałem dla lodos, nie dlatego, że lepiej sandboksujemy, nie dlatego, że szybciej łatamy, ale dlatego, że nigdzie w pipeline nie ma ewaluatora wyrażeń, do którego dałoby się coś wstrzyknąć. Skrypt builda go wyszukuje grepem. Jeśli przyszły ja doda eval, new Function albo vm.runInContext do powierzchni workflow, build zrobi się czerwony, zanim commit zostanie scalony.
Ten wpis to przegląd architektury: co „declarative-bounded" (deklaratywny i ograniczony) faktycznie oznacza na poziomie schematu YAML, dlaczego celowo zrezygnowałem z zupełności Turinga, oraz jaki konkretny grep pilnuje tej granicy.
Jeśli budujesz narzędzia do workflow, albo jakikolwiek system, który przyjmuje wyrażenia dostarczone przez użytkownika i je uruchamia, ten kompromis warto uczciwie przeanalizować. n8n wybrało inny i dostało za to RCE o CVSS 9.9. Ja wybrałem ten i mam silnik workflow, który jest ściśle mniej ekspresywny niż ich. Wybierz swój kompromis świadomie.
Co „deklaratywność" właściwie tutaj oznacza
Silnik workflow lodos udostępnia pięć prymitywów. To wszystko:
http_request: metoda, szablon URL, nagłówki, body, lista hostówallowedEgress,timeoutMsdb_query: wyłącznie SELECT na lokalnej bazie SQLite, ze statyczną listą zakazanych tabel dla vault/billingai_call: zaszyty na stałe egress doapi.anthropic.com,noSecrets: true(transkrypt oczyszczany przed wysłaniem)tool_call: narzędzia MCP tylko-do-odczytu-auto, bramkowane przez guard, z obsługą opcjonalnej degradacji- Sterowanie przepływem:
wait,if_else,loopz komparatorami o zamkniętym enumie (eq,neq,gt,lt,changed-since-last-run)
To ostatnia linijka, której chcę bronić. Większość silników workflow, jakie widziałem, n8n, Zapier, Temporal, pozwala nawet wpisać wyrażenie JS w polu warunku. Jeśli ta liczba jest większa niż 5, skręć w lewo. Ewaluator wyrażeń jest wygodny dla użytkowników i przepaścią dla zespołów bezpieczeństwa. Więc go nie mamy. Komparator jest enumem; porównanie jest danymi; nie istnieje żadna ścieżka od pola YAML edytowanego przez użytkownika do wywołania funkcji.
Coś się traci. Nie możesz napisać if step.result.users.filter(u => u.active).length > 5. Możesz napisać if step.result.activeUserCount gt 5 i wyprodukować activeUserCount wcześniej, w kroku db_query albo ai_call. Obliczenia przenoszą się do prymitywów, które już istnieją; definicja workflow pozostaje deklaratywna.

Dyscyplina schematu
YAML workflow jest parsowany przez schemat Zod z z.lazy do obsługi rekurencji (pętle i if_else mogą się zagnieżdżać). Nieograniczone z.lazy to po prostu inna nazwa dla eval, złośliwy YAML może wysadzić stos albo stertę, zanim jakikolwiek handler zdąży się uruchomić. Dlatego schemat jest podwójnie ograniczony:
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;

Trzy ograniczenia, każde z nich konieczne. per-array .max(16) nie pozwala, by pojedynczy blok stał się megabajtem zagnieżdżonych gałęzi. total-nodes 100 ogranicza cały graf (nie da się przemycić złożoności obok limitu per-tablicę, rozkładając ją cienko). depth 5 sprawia, że rekurencyjne przejście jest stałoczasowe względem wejścia. Żadne z tych trzech nie jest paranoją; każde zamyka klasę ataku na zasoby, którego prawdziwy użytkownik workflow nigdy by nie napisał, ale wrogi YAML, owszem.
Dlaczego secret_value_get nie istnieje
W systemie nie ma żadnego narzędzia MCP, którego kontrakt brzmiałby podaj mi jawny tekst sekretu. Jest sześciowarstwowy secret_inject_and_run, który przyjmuje referencję do sekretu i argv, ustawia wartość w env subprocesu, uruchamia komendę i nigdy nie zwraca tej wartości wywołującemu AI. To wszystko.
To nie jest polityka („nie dodawaj takiego narzędzia"). Skrypt builda asercjuje jego brak:
// 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);
}
}
Jeśli przyszły ja, albo przyszły edytor AI pracujący nad tą bazą kodu, przekona samego siebie, że potrzebuje narzędzia zwracającego wartość sekretu, build odmówi, zanim dojdzie do merge'a.
INV-M5: pułapka na eval
Odpowiadająca temu kontrola dla silnika workflow jest jeszcze prostsza:
// 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);
}
}
}
}
Pięćdziesiąt linii Node, zero zależności, uruchamiane w standardowym kroku pnpm verify. Cała klasa CVE, w której żyje RCE-przez-wyrażenia n8n o CVSS 9.9, jest zamknięta przez grepa.
Dyscyplina, która czyni to realnym, to dowód negatywny: skrypt jest dostarczany razem z własnym fałszywym naruszeniem. Jednorazowy plik __probe.ts z dosłownym eval( w środku. CI uruchamia skrypt dwukrotnie, najpierw z wstrzykniętą sondą (musi zakończyć się kodem 1), potem po jej usunięciu (musi zakończyć się kodem 0). Detektor odmów, który nigdy nie zawodzi, jest nie do odróżnienia od braku detektora w ogóle; dowód negatywny to właśnie to, co daje temu skryptowi kły.
Rozwój funkcjonalności poprzez zawężanie, nie rozluźnianie
Pytanie sceptycznego czytelnika: czy to się nie sypie, gdy potrzeba więcej funkcji?
Kiedy wypuściłem web_fetch dla egressu webowego L2, nie dodałem ewaluatora. Dodałem: wąską allowlistę hostów + budżet bajtów per źródło + ponowną autoryzację przy przekierowaniu + ochronę przed SSRF-przeciw-sobie + zaporę renderującą + propagację skażonej treści. Dwadzieścia sześć nowych przypadków testowych, zero nowej powierzchni eval. Silnik stał się potężniejszy przez dodanie ograniczeń, a nie przez dodanie mocy wyrażeń.
Da się tak działać. Większość rozwoju narzędzi workflow interpretuje się jako „daj użytkownikowi więcej powierzchni eval". Można to zinterpretować inaczej: „daj użytkownikowi więcej ograniczonych prymitywów". To drugie jest trudniejsze do zaprojektowania i niemożliwe do rozrośnięcia się w CVSS 9.9.
Co to kosztuje i dlaczego za to zapłaciłem
Rezygnujesz z: dowolnych obliczeń wewnątrz kroku, warunków w postaci wyrażeń JS, dynamicznej projekcji pól, wszystkiego, co użytkownik może zrobić w n8n za pomocą {{ $json.foo.map(x => x * 2) }}. Prawdziwi użytkownicy workflow rzeczywiście po to sięgają, a w lodos sięgają po to, pisząc jeszcze jeden krok ai_call albo db_query. Definicja workflow pozostaje deklaratywna; powierzchnia eval pozostaje pusta.
Zyskujesz: silnik workflow, w którym CVE-2025-68613 jest strukturalnie niemożliwe, w którym schemat wymusza ograniczone zużycie zasobów, i w którym każda odmowa jest egzekwowana przez grepa, nie przez docstring.
Następny wpis z serii schodzi o jedno piętro niżej, do vaulta. "AI sk_live'ı göremez", AI nie może zobaczyć twojego klucza live Stripe, to twierdzenie architektoniczne, a fragment schematu, który je potwierdza, jest tym, z którego jestem najbardziej dumny. Zero-Knowledge jako architektoniczna ślepota wyjaśnia, dlaczego nasz schemat Prisma dosłownie nie może przechowywać jawnej ścieżki pola, i jak to daje nam za darmo historię audytu klasy SOC2.