workflow-engine
n8n'in CVE'sine Sahip Olamayan Bir Workflow Motoru İnşa Ettik (1/5)
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,allowedEgresshost listesi,timeoutMsdb_query: yerel SQLite'a karşı yalnızca SELECT, vault/billing tabloları için statik bir denylist ileai_call: hardcodedapi.anthropic.comegress,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) sahipwait,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.

Ş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:
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;

Üç 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:
// 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:
// 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.