# Zbudowaliśmy silnik workflow, który nie może mieć CVE n8n (1/5)

Source: https://lodos.md/pl/blog/silnik-workflow-bez-cve-n8n
Published: 2026-07-02 · Author: Samet Samyeli · Language: pl · Reading time: 6 min

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ów `allowedEgress`, `timeoutMs`
- `db_query`: wyłącznie SELECT na lokalnej bazie SQLite, ze statyczną listą zakazanych tabel dla vault/billing
- `ai_call`: zaszyty na stałe egress do `api.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`, `loop` z **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.

![Warunki n8n oparte na wyrażeniach kompilują dane wejściowe użytkownika do wykonywalnego kodu; deklaratywne komparatory lodos zachowują dane wejściowe użytkownika jako dane, nie kod.](/blog/workflow-engine-cant-have-n8n-cve/2a.webp)

## 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:

```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;
```

![Silnik workflow lodos: deklaratywny YAML ograniczony schematem Zod, głębokość ≤ 5, łączna liczba węzłów ≤ 100, ≤ 16 elementów potomnych na tablicę, nad pięcioma prymitywami, bez eval, bez Function, bez vm.](/blog/workflow-engine-cant-have-n8n-cve/3a.webp)

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:

```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);
  }
}
```

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:

```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);
      }
    }
  }
}
```

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.
