# n8n'in CVE'sine Sahip Olamayan Bir Workflow Motoru İnşa Ettik (1/5)

Source: https://lodos.md/tr/blog/n8n-cve-alamayan-workflow-motoru
Published: 2026-07-02 · Author: Samet Samyeli · Language: tr · Reading time: 5 min

n8n, expression eval üzerinden CVSS 9.9 puanlı bir RCE açığı yayınladı. lodos workflow motorunda bu hata olamaz, enjekte edilecek hiçbir evaluator yok ve build-time bir grep bunu böyle tutuyor.

---

n8n geçen ay **CVE-2025-68613**'ü aldı, CVSS 9.9, expression eval üzerinden RCE. İlginç olan kısım bu değil.

İlginç olan kısım, bu açık sınıfının tamamının lodos için tasarladığım workflow motorunda yapısal olarak yok olması, daha iyi sandbox'ladığımız için değil, daha hızlı patch geçtiğimiz için değil, pipeline'ın hiçbir yerinde enjekte edilebilecek bir expression evaluator olmadığı için. Build script'i bunu grep'ler. Gelecekte bir ben workflow yüzeyine `eval` veya `new Function` veya `vm.runInContext` eklerse, build commit düşmeden kırmızıya döner.

Bu yazı bir mimari gezintisi: "declarative-bounded" YAML şeması seviyesinde gerçekte ne anlama geliyor, Turing-completeness'ten neden bilinçli olarak vazgeçtim ve bu çizgiyi tutan grep tam olarak nedir.

Workflow tooling inşa ediyorsanız, ya da kullanıcı tarafından sağlanan expression'ları alıp çalıştıran herhangi bir sistem, bu trade-off'u dürüstçe incelemeye değer. n8n farklı bir tercih yaptı ve bunun için CVSS 9.9 bir RCE'leri oldu. Ben bu tercihi yaptım ve onlarınkinden kesinlikle daha az ifade gücüne sahip bir workflow motorum var. Trade-off'unuzu bilerek seçin.

## "Declarative" burada gerçekte ne demek

lodos workflow motoru beş primitive açığa çıkarır. Hepsi bu kadar:

- `http_request`: method, URL template, header'lar, body, `allowedEgress` host listesi, `timeoutMs`
- `db_query`: yerel SQLite'a karşı yalnızca SELECT, vault/billing tabloları için statik bir denylist ile
- `ai_call`: hardcoded `api.anthropic.com` egress, `noSecrets: true` (gönderilmeden önce transcript temizlenir)
- `tool_call`: read-only-auto MCP tool'ları, guard-gated, optional-degrade destekli
- Control flow: **kapalı-enum karşılaştırıcılara** (`eq`, `neq`, `gt`, `lt`, `changed-since-last-run`) sahip `wait`, `if_else`, `loop`

Savunmak istediğim satır o son satır. Gördüğüm çoğu workflow motoru, n8n, Zapier, Temporal, condition slot'una JS expression yazmanıza bile izin veriyor. *Bu sayı 5'ten büyükse, sola dallan.* Expression evaluator kullanıcı için kullanışlı, güvenlik ekipleri için ise bir uçurum. Biz de bunu barındırmıyoruz. Karşılaştırıcı bir enum; karşılaştırma veri; kullanıcının düzenlediği bir YAML alanından bir fonksiyon çağrısına giden hiçbir yol yok.

Bir şeylerden vazgeçiyorsunuz. `if step.result.users.filter(u => u.active).length > 5` yazamazsınız. `if step.result.activeUserCount gt 5` yazabilir ve `activeUserCount`'u bir `db_query` veya `ai_call` içinde upstream'de üretebilirsiniz. Hesaplama zaten var olan primitive'lere taşınır; workflow tanımı declarative kalır.

![n8n'in expression tabanlı koşulları kullanıcı girdisini çalıştırılabilir koda derler; lodos'un declarative karşılaştırıcıları kullanıcı girdisini kod değil veri olarak tutar.](/blog/workflow-engine-cant-have-n8n-cve/2a.webp)

## Şema disiplini

Workflow YAML'ı, recursion için `z.lazy` kullanan bir Zod şeması üzerinden parse edilir (loop'lar ve `if_else` iç içe geçebilir). Sınırsız `z.lazy`, eval'in farklı bir adıdır, kötü niyetli bir YAML, herhangi bir handler çalışmadan önce stack'i veya heap'i patlatabilir. Bu yüzden şema çift sınırlıdır:

```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 workflow motoru: derinlik ≤ 5, toplam node ≤ 100, dizi başına ≤ 16 çocuk ile sınırlanmış, beş primitive üzerinden çalışan declarative YAML, eval yok, Function yok, vm yok.](/blog/workflow-engine-cant-have-n8n-cve/3a.webp)

Üç sınır, her biri gerekli. `per-array .max(16)`, tek bir bloğun megabyte'larca iç içe geçmiş dal olmasını engeller. `total-nodes 100`, tüm grafiği sınırlar (per-array sınırını inceltip yayarak karmaşıklığı kaçırmazsınız). `depth 5`, recursive gezinmeyi girdiye göre sabit-zamanlı tutar. Bu üçünden hiçbiri paranoya değil; her biri, gerçek bir workflow kullanıcısının asla yazmayacağı ama düşman bir YAML'ın yazacağı bir kaynak saldırısı sınıfını kapatır.

## `secret_value_get` neden yok

Sistemde kontratı *bana bir secret'ın plaintext'ini ver* olan hiçbir MCP tool'u yoktur. Bir secret referansı ve bir argv alan, değeri bir subprocess env'ine set eden, komutu çalıştıran ve değeri çağıran AI'a asla döndürmeyen altı katmanlı bir `secret_inject_and_run` var. Hepsi bu.

Bu bir politika değil ("böyle bir tool eklemeyin"). Build script'i bu yokluğu assert eder:

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

Gelecekte bir ben, ya da bu codebase üzerinde çalışan gelecekteki bir AI editörü, değer döndüren bir secret tool'una ihtiyaçları olduğuna kendini ikna ederse, build merge'den önce reddeder.

## INV-M5: eval tripwire'ı

Workflow motoru için karşılık gelen kontrol daha da basit:

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

Elli satır Node kodu, sıfır dependency, standart `pnpm verify` adımında çalışır. n8n'in CVSS 9.9 expression-RCE'sinin yaşadığı CVE sınıfının tamamı bir grep ile kapatılır.

Bunu gerçek kılan disiplin **negatif kanıt**: script kendi sahte ihlaliyle birlikte gelir. İçinde literal bir `eval(` bulunan, atılabilir bir `__probe.ts` dosyası. CI script'i iki kez çalıştırır, önce probe enjekte edilmişken (exit 1 vermeli), sonra kaldırılmışken (exit 0 vermeli). Hiç başarısız olmayan bir refusal-detector, detector olmamasından ayırt edilemez; script'e diş veren şey bu negatif kanıttır.

## Özellik büyümesi daraltma yoluyla, gevşetme yoluyla değil

Şüpheci okuyucunun sorusu: daha fazla özelliğe ihtiyaç duyduğunuzda bu kırılmıyor mu?

L2 web egress için `web_fetch`'i yayınladığımda bir evaluator eklemedim. Şunları ekledim: dar host allowlist + kaynak başına byte bütçesi + redirect-reauth + kendine-karşı-SSRF koruması + render-firewall + tainted-content yayılımı. Yirmi altı yeni test case, sıfır yeni eval yüzeyi. Motor, *expression gücü* ekleyerek değil *sınır* ekleyerek daha güçlü hale geldi.

Bunu yapabilirsiniz. Workflow tool'larındaki büyümenin çoğu "kullanıcıya daha fazla eval yüzeyi ver" olarak yorumlanır. Bunun yerine "kullanıcıya daha fazla sınırlı primitive ver" olarak da yorumlanabilir. İkincisi tasarlaması daha zor ve CVSS 9.9'a büyümesi imkansız.

## Bunun bedeli ne ve neden ödedim

Şunlardan vazgeçiyorsunuz: adım-içi keyfi hesaplama, JS expression koşulları, dinamik alan projeksiyonu, n8n'in `{{ $json.foo.map(x => x * 2) }}`'inde kullanıcının yapabileceği her şey. Gerçek workflow kullanıcıları bunlara gerçekten uzanıyor ve lodos'ta bunu yaptıklarında bir `ai_call` veya `db_query` adımı daha yazarak uzanıyorlar. Workflow tanımı declarative kalır; eval yüzeyi boş kalır.

Karşılığında şunu alıyorsunuz: CVE-2025-68613'ün yapısal olarak imkansız olduğu, şemanın sınırlı kaynak tüketimini zorunlu kıldığı ve her reddin bir docstring ile değil bir grep ile zorunlu kılındığı bir workflow motoru.

---

Serideki bir sonraki yazı bir katman daha aşağı, vault'a iniyor. *"AI sk_live'ı göremez"*, yapısal iddia bu, ve bunu destekleyen şema parçası en gurur duyduğum kısım. **Zero-Knowledge as Architectural Blindness**, Prisma şemamızın neden kelimenin tam anlamıyla plaintext bir field path tutamadığını ve bunun bize SOC2 seviyesinde bir audit hikayesini nasıl bedavaya kazandırdığını anlatıyor.
