workflow-engine

우리는 n8n의 CVE가 애초에 존재할 수 없는 워크플로우 엔진을 만들었다 (1/5)

n8n은 표현식 eval을 통해 CVSS 9.9 등급의 RCE를 배포한 적이 있다. lodos의 워크플로우 엔진에는 이런 버그가 원천적으로 존재할 수 없다, 주입할 evaluator 자체가 파이프라인 어디에도 없고, 빌드 타임 grep이 그 상태를 계속 지켜주기 때문이다.

5 분 분량

n8n은 지난달 CVE-2025-68613을 겪었다, CVSS 9.9, 표현식 eval을 통한 RCE. 하지만 흥미로운 지점은 거기가 아니다.

진짜 흥미로운 부분은, lodos를 위해 내가 설계한 워크플로우 엔진에는 이 취약점 클래스 자체가 구조적으로 존재하지 않는다는 사실이다, 우리가 샌드박스를 더 잘 만들어서도, 패치를 더 빨리 해서도 아니다. 파이프라인 어디에도 주입할 수 있는 표현식 evaluator가 아예 없기 때문이다. 빌드 스크립트가 이를 grep으로 검사한다. 미래의 내가 워크플로우 표면에 eval이나 new Function, vm.runInContext를 추가하려 든다면, 커밋이 반영되기도 전에 빌드가 빨갛게 실패한다.

이 글은 그 아키텍처를 짚어본다. YAML 스키마 레벨에서 "declarative-bounded"가 실제로 무엇을 의미하는지, 왜 내가 튜링 완전성을 의도적으로 포기했는지, 그리고 그 선을 지켜주는 구체적인 grep이 무엇인지.

워크플로우 툴링을 만들고 있다면, 혹은 사용자가 제공한 표현식을 실행하는 어떤 시스템이든, 이 트레이드오프는 정직하게 따져볼 가치가 있다. n8n은 다른 선택을 했고, 그 대가로 CVSS 9.9짜리 RCE를 얻었다. 나는 이런 선택을 했고, 그 결과 그들의 것보다 표현력이 명백히 떨어지는 워크플로우 엔진을 갖게 됐다. 어느 쪽을 택할지는 알고 선택하자.

"선언적(declarative)"이 실제로 의미하는 것

lodos 워크플로우 엔진은 다섯 가지 프리미티브만 노출한다. 그게 전부다.

  • http_request: method, URL 템플릿, 헤더, body, allowedEgress 호스트 목록, timeoutMs
  • db_query: 로컬 SQLite에 대한 SELECT 전용, vault/billing 테이블은 정적 denylist로 차단
  • ai_call: api.anthropic.com으로 하드코딩된 egress, noSecrets: true(전송 전 transcript에서 제거)
  • tool_call: read-only-auto MCP 도구, guard로 게이트되며 optional-degrade 지원
  • 제어 흐름: wait, if_else, loop이며 닫힌 열거형(closed-enum) 비교 연산자(eq, neq, gt, lt, changed-since-last-run)만 사용

내가 방어하고 싶은 지점이 바로 이 마지막 줄이다. 내가 본 대부분의 워크플로우 엔진, n8n, Zapier, Temporal, 은 조건 슬롯에 JS 표현식을 직접 쓸 수 있게 해준다. 이 숫자가 5보다 크면 왼쪽으로 분기. 표현식 evaluator는 사용자에게는 편리하지만 보안 팀에게는 절벽이다. 그래서 우리는 그걸 두지 않았다. 비교 연산자는 열거형(enum)이고, 비교 대상은 데이터이며, 사용자가 수정한 YAML 필드에서 함수 호출로 이어지는 경로 자체가 없다.

물론 잃는 것도 있다. if step.result.users.filter(u => u.active).length > 5는 쓸 수 없다. 대신 if step.result.activeUserCount gt 5라고 쓰고, activeUserCountdb_queryai_call에서 미리 만들어내면 된다. 연산은 이미 존재하는 프리미티브 쪽으로 옮겨가고, 워크플로우 정의 자체는 선언적으로 남는다.

n8n의 표현식 기반 조건문은 사용자 입력을 실행 가능한 코드로 컴파일하지만, lodos의 선언적 비교 연산자는 사용자 입력을 코드가 아닌 데이터로 유지한다.

스키마 규율

워크플로우 YAML은 재귀(루프와 if_else가 중첩될 수 있다)를 위해 z.lazy를 사용하는 Zod 스키마를 통해 파싱된다. 경계가 없는 z.lazy는 사실상 eval의 다른 이름일 뿐이다, 악의적인 YAML은 어떤 핸들러가 실행되기도 전에 스택이나 힙을 터뜨릴 수 있다. 그래서 이 스키마는 이중으로 경계가 설정되어 있다.

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 워크플로우 엔진: 다섯 가지 프리미티브에 걸쳐 depth ≤ 5, 전체 노드 ≤ 100, 배열당 자식 ≤ 16개로 Zod 스키마가 경계를 설정한 선언적 YAML, eval도, Function도, vm도 없다.

세 가지 경계 모두 필요하다. per-array .max(16)은 어느 한 블록이 수 메가바이트짜리 중첩 분기 덩어리가 되는 것을 막는다. total-nodes 100은 그래프 전체를 제한한다(배열당 상한을 얇게 펴서 우회해 복잡도를 몰래 늘리는 걸 막는다). depth 5는 재귀 순회가 입력 크기에 비례해 상수 시간으로 유지되도록 한다. 이 셋 중 어느 것도 과잉 방어가 아니다. 각각이 진짜 워크플로우 사용자라면 결코 작성하지 않을, 그러나 악의적인 YAML이라면 시도할 리소스 공격 클래스를 하나씩 닫아준다.

secret_value_get이 존재하지 않는 이유

이 시스템에는 비밀값의 평문을 달라는 계약을 가진 MCP 도구가 없다. 대신 6단계로 구성된 secret_inject_and_run이 있는데, 이는 시크릿 참조와 argv를 받아 서브프로세스 환경 변수에 값을 세팅하고 명령을 실행한 뒤, 호출한 AI에게는 절대 그 값을 돌려주지 않는다. 그게 전부다.

이건 정책("이런 도구는 만들지 않는다")이 아니다. 빌드 스크립트가 그 부재를 직접 단언한다.

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

미래의 나, 혹은 이 코드베이스를 손보는 미래의 AI 편집자, 가 값을 반환하는 시크릿 도구가 필요하다고 스스로를 설득하더라도, 병합되기 전에 빌드가 먼저 거부한다.

INV-M5: eval 트립와이어

워크플로우 엔진에 대응하는 검사는 훨씬 더 단순하다.

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

Node 코드 50줄, 의존성 제로, 표준 pnpm verify 단계에서 실행된다. n8n의 CVSS 9.9짜리 표현식-RCE가 속해 있는 CVE 클래스 전체가 grep 하나로 닫힌다.

이걸 진짜로 만들어주는 규율은 **네거티브 프루프(negative proof)**다. 이 스크립트는 자체적인 가짜 위반 사례와 함께 배포된다. 리터럴 eval(을 담은 일회용 __probe.ts 파일. CI는 이 스크립트를 두 번 실행한다, 먼저 프로브가 주입된 상태로(반드시 exit 1이어야 함), 그다음 프로브를 제거한 상태로(반드시 exit 0이어야 함). 절대 실패하지 않는 거부 탐지기는 탐지기가 없는 것과 구별이 안 된다. 네거티브 프루프야말로 이 스크립트에 이빨을 달아주는 요소다.

기능 확장은 완화가 아니라 축소를 통해

회의적인 독자라면 이렇게 물을 것이다. 더 많은 기능이 필요해지면 이 구조가 깨지지 않을까?

L2 web egress를 위해 web_fetch를 배포했을 때, 나는 evaluator를 추가하지 않았다. 대신 이렇게 했다: 좁은 호스트 allowlist, 소스별 바이트 예산, 리다이렉트 재인증, SSRF-against-self 방어, 렌더링 방화벽, 오염된 콘텐츠 전파(tainted-content propagation) 추적. 새로운 테스트 케이스 26개, 새로운 eval 표면은 0개. 이 엔진은 표현식 능력을 더해서가 아니라 경계를 더해서 더 강력해졌다.

이런 방식은 얼마든지 가능하다. 워크플로우 툴에서의 기능 확장 대부분은 "사용자에게 더 많은 eval 표면을 준다"로 해석된다. 하지만 그 대신 "사용자에게 더 많은 경계 지어진 프리미티브를 준다"로 해석할 수도 있다. 후자는 설계하기 더 어렵지만, CVSS 9.9로 성장할 가능성이 없다.

이 선택의 대가와 그것을 치른 이유

포기한 것: 스텝 내부의 임의 연산, JS 표현식 조건문, 동적 필드 프로젝션, n8n의 {{ $json.foo.map(x => x * 2) }}로 사용자가 할 수 있는 것이라면 무엇이든. 실제 워크플로우 사용자들은 분명 이런 것들을 찾는다. lodos에서는 그럴 때 ai_call이나 db_query 스텝을 하나 더 추가하는 방식으로 손을 뻗는다. 워크플로우 정의는 선언적인 상태를 유지하고, eval 표면은 비어있는 상태를 유지한다.

얻은 것: CVE-2025-68613이 구조적으로 불가능한 워크플로우 엔진, 스키마가 제한된 리소스 소비를 강제하는 구조, 그리고 모든 거부가 docstring이 아니라 grep으로 강제되는 시스템.


이 시리즈의 다음 글은 한 계층 더 아래, vault로 내려간다. "AI sk_live'ı göremez", AI는 당신의 Stripe live key를 볼 수 없다, 라는 문장이 그 아키텍처의 핵심 주장이며, 이를 뒷받침하는 스키마 조각이야말로 내가 가장 자랑스럽게 여기는 부분이다. Zero-Knowledge as Architectural Blindness는 우리 Prisma 스키마가 왜 문자 그대로 평문 필드 경로를 담을 수 없는 구조인지, 그리고 그 구조가 어떻게 SOC2급 감사 스토리를 공짜로 얻게 해주는지를 설명한다.

workflow-engine보안architecturezero-knowledge

내 컴퓨터에서 회사를 운영하세요.

대기자 명단에 등록하고 lodos가 열릴 때 가장 먼저 시작하세요.