# Wir haben eine Workflow-Engine gebaut, die n8n's CVE gar nicht haben kann (1/5)

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

n8n hat einen CVSS 9.9 RCE über Expression-Eval ausgeliefert. Die lodos Workflow-Engine kann diesen Bug nicht haben, es gibt nirgendwo einen Evaluator, in den man injizieren könnte, und ein Build-Time-Grep sorgt dafür, dass das so bleibt.

---

n8n hatte letzten Monat **CVE-2025-68613**, CVSS 9.9, RCE über Expression-Eval. Das ist nicht der interessante Teil.

Der interessante Teil ist, dass diese gesamte Klasse von Schwachstellen in der Workflow-Engine, die ich für lodos entworfen habe, strukturell gar nicht existieren kann, nicht weil wir besser sandboxen, nicht weil wir schneller patchen, sondern weil es in der gesamten Pipeline nirgendwo einen Expression-Evaluator gibt, in den man injizieren könnte. Das Build-Skript greppt danach. Sollte ein zukünftiges Ich `eval`, `new Function` oder `vm.runInContext` zur Workflow-Oberfläche hinzufügen, wird der Build rot, bevor der Commit landet.

Dieser Beitrag ist der Architektur-Walkthrough: Was "deklarativ-begrenzt" auf Ebene des YAML-Schemas tatsächlich bedeutet, warum ich mich bewusst gegen Turing-Vollständigkeit entschieden habe, und der konkrete Grep, der diese Linie hält.

Wenn du Workflow-Tooling baust, oder irgendein System, das benutzerdefinierte Expressions entgegennimmt und ausführt, lohnt es sich, diesen Trade-off ehrlich zu betrachten. n8n hat einen anderen gewählt und dafür einen CVSS-9.9-RCE bekommen. Ich habe diesen hier gewählt und habe eine Workflow-Engine, die strikt weniger ausdrucksstark ist als ihre. Wähle deinen Trade-off bewusst.

## Was "deklarativ" hier tatsächlich bedeutet

Die lodos Workflow-Engine stellt fünf Primitive bereit. Das war's:

- `http_request`: Methode, URL-Template, Header, Body, `allowedEgress`-Host-Liste, `timeoutMs`
- `db_query`: nur SELECT gegen die lokale SQLite, mit einer statischen Denylist für Vault-/Billing-Tabellen
- `ai_call`: fest verdrahtetes `api.anthropic.com`-Egress, `noSecrets: true` (Transcript wird vor dem Senden bereinigt)
- `tool_call`: read-only-auto MCP-Tools, guard-gated, unterstützt optional-degrade
- Kontrollfluss: `wait`, `if_else`, `loop` mit **geschlossenen Enum-Comparatoren** (`eq`, `neq`, `gt`, `lt`, `changed-since-last-run`)

Diese letzte Zeile ist die, die ich verteidigen möchte. Die meisten Workflow-Engines, die ich gesehen habe, n8n, Zapier, Temporal, lassen dich sogar einen JS-Ausdruck im Bedingungs-Slot schreiben. *Wenn diese Zahl größer als 5 ist, verzweige nach links.* Der Expression-Evaluator ist bequem für Nutzer und ein Abgrund für Security-Teams. Also haben wir keinen. Der Comparator ist ein Enum; der Vergleichswert ist Daten; es gibt keinen Pfad von einem benutzerbearbeiteten YAML-Feld zu einem Funktionsaufruf.

Man verliert dadurch etwas. Man kann nicht schreiben: `if step.result.users.filter(u => u.active).length > 5`. Man kann aber schreiben: `if step.result.activeUserCount gt 5` und `activeUserCount` vorgelagert in einem `db_query`- oder `ai_call`-Schritt erzeugen. Die Berechnung wandert zu den bereits existierenden Primitiven; die Workflow-Definition bleibt deklarativ.

![n8ns expression-basierte Bedingungen kompilieren Benutzereingaben zu ausführbarem Code; lodos' deklarative Comparatoren halten Benutzereingaben als Daten, nicht als Code.](/blog/workflow-engine-cant-have-n8n-cve/2a.webp)

## Die Schema-Disziplin

Das Workflow-YAML wird über ein Zod-Schema mit `z.lazy` für Rekursion geparst (Loops und `if_else` können verschachtelt sein). Unbegrenztes `z.lazy` ist nur ein anderer Name für Eval, ein bösartiges YAML kann den Stack oder den Heap sprengen, bevor überhaupt ein Handler läuft. Deshalb ist das Schema doppelt begrenzt:

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

![Die lodos Workflow-Engine: deklaratives YAML, begrenzt durch ein Zod-Schema, Tiefe ≤ 5, Gesamtknoten ≤ 100, ≤ 16 Kinder pro Array, über fünf Primitive, ohne eval, ohne Function, ohne vm.](/blog/workflow-engine-cant-have-n8n-cve/3a.webp)

Drei Grenzen, jede davon notwendig. `per-array .max(16)` verhindert, dass ein einzelner Block zu einem Megabyte verschachtelter Verzweigungen wird. `total-nodes 100` begrenzt den gesamten Graphen (man kann Komplexität nicht am Per-Array-Limit vorbeischmuggeln, indem man sie dünn verteilt). `depth 5` hält den rekursiven Durchlauf relativ zur Eingabe konstant-zeitig. Keine der drei ist Paranoia; jede schließt eine Klasse von Ressourcen-Angriffen, die ein echter Workflow-Nutzer nie schreiben würde, ein feindseliges YAML aber schon.

## Warum `secret_value_get` nicht existiert

Es gibt kein MCP-Tool im System, dessen Vertrag lautet *gib mir den Klartext eines Secrets*. Es gibt ein sechsstufiges `secret_inject_and_run`, das eine Secret-Referenz und ein argv entgegennimmt, den Wert in einer Subprocess-Env setzt, den Befehl ausführt und den Wert niemals an die aufrufende KI zurückgibt. Das war's.

Das ist keine Policy ("füge kein solches Tool hinzu"). Das Build-Skript prüft aktiv die Abwesenheit:

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

Sollte ein zukünftiges Ich, oder ein zukünftiger KI-Editor, der an dieser Codebasis arbeitet, sich davon überzeugen, ein wertzurückgebendes Secret-Tool zu brauchen, verweigert der Build den Merge.

## INV-M5: der Eval-Stolperdraht

Die entsprechende Prüfung für die Workflow-Engine ist noch einfacher:

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

Fünfzig Zeilen Node, null Abhängigkeiten, läuft im Standard-`pnpm verify`-Schritt. Die gesamte CVE-Klasse, in der n8ns CVSS-9.9-Expression-RCE lebt, wird durch einen Grep geschlossen.

Die Disziplin, die das real macht, ist der **Negativbeweis**: Das Skript wird mit seiner eigenen fingierten Verletzung ausgeliefert. Eine Wegwerf-Datei `__probe.ts` mit einem literalen `eval(` darin. CI führt das Skript zweimal aus, zuerst mit injizierter Probe (muss mit 1 beenden), dann ohne sie (muss mit 0 beenden). Ein Refusal-Detektor, der niemals fehlschlägt, ist nicht von gar keinem Detektor zu unterscheiden; der Negativbeweis ist das, was diesem Skript Biss verleiht.

## Feature-Wachstum durch Verengung, nicht durch Lockerung

Die Frage des skeptischen Lesers: Bricht das nicht zusammen, sobald man mehr Features braucht?

Als ich `web_fetch` für L2-Web-Egress ausgeliefert habe, habe ich keinen Evaluator hinzugefügt. Ich habe hinzugefügt: eine enge Host-Allowlist + Byte-Budget pro Quelle + Redirect-Reauth + SSRF-gegen-sich-selbst-Schutz + Render-Firewall + Taint-Propagation für kontaminierten Inhalt. Sechsundzwanzig neue Testfälle, null neue Eval-Oberfläche. Die Engine wurde mächtiger, indem *Grenzen* hinzukamen, nicht indem *Expression-Power* hinzukam.

Das kann man so machen. Die meisten Wachstumsschritte bei Workflow-Tools werden interpretiert als "gib dem Nutzer mehr Eval-Oberfläche". Man kann es stattdessen interpretieren als "gib dem Nutzer mehr begrenzte Primitive". Letzteres ist schwerer zu entwerfen und unmöglich zu einem CVSS 9.9 auswachsen zu lassen.

## Was das kostet und warum ich es bezahlt habe

Man verzichtet auf: beliebige Berechnung innerhalb eines Schritts, JS-Expression-Bedingungen, dynamische Feld-Projektion, alles, was ein Nutzer in n8ns `{{ $json.foo.map(x => x * 2) }}` tun kann. Echte Workflow-Nutzer greifen tatsächlich danach, und wenn sie es in lodos tun, greifen sie danach, indem sie einen weiteren `ai_call`- oder `db_query`-Schritt schreiben. Die Workflow-Definition bleibt deklarativ; die Eval-Oberfläche bleibt leer.

Man bekommt dafür: eine Workflow-Engine, in der CVE-2025-68613 strukturell unmöglich ist, in der das Schema begrenzten Ressourcenverbrauch erzwingt, und in der jede Verweigerung durch einen Grep durchgesetzt wird, nicht durch einen Docstring.

---

Der nächste Beitrag der Serie geht eine Ebene tiefer, in den Vault. *"AI sk_live'ı göremez"*, die KI kann deinen Stripe-Live-Key nicht sehen, ist die architektonische Behauptung, und das Schema-Fragment, das sie stützt, ist dasjenige, auf das ich am meisten stolz bin. **Zero-Knowledge als architektonische Blindheit** erklärt, warum unser Prisma-Schema buchstäblich keinen Klartext-Feldpfad halten kann, und wie uns das die SOC2-taugliche Audit-Story kostenlos verschafft.
