workflow-engine

Nous avons construit un moteur de workflow qui ne peut pas avoir la CVE de n8n (1/5)

n8n a livré un RCE CVSS 9.9 via l''évaluation d''expressions. Le moteur de workflow de lodos ne peut pas avoir ce bug, il n''y a nulle part d''évaluateur dans lequel injecter, et un grep en build-time garantit que ça le reste.

7 min de lecture

n8n a eu droit à CVE-2025-68613 le mois dernier, CVSS 9.9, RCE via évaluation d'expressions. Ce n'est pas la partie intéressante.

La partie intéressante, c'est que toute cette classe de vulnérabilité est structurellement absente du moteur de workflow que j'ai conçu pour lodos, pas parce qu'on sandboxe mieux, pas parce qu'on a patché plus vite, mais parce qu'il n'existe nulle part dans le pipeline d'évaluateur d'expressions dans lequel injecter quoi que ce soit. Le script de build le grep. Si un futur moi ajoute eval, new Function ou vm.runInContext à la surface du moteur de workflow, le build passe au rouge avant même que le commit n'atterrisse.

Cet article est une visite guidée de l'architecture : ce que « déclaratif-borné » signifie concrètement au niveau du schéma YAML, pourquoi j'ai choisi de perdre volontairement la Turing-complétude, et le grep précis qui tient la ligne.

Si vous construisez de l'outillage de workflow, ou n'importe quel système qui prend des expressions fournies par l'utilisateur et les exécute, ce compromis mérite d'être examiné honnêtement. n8n en a fait un autre, et ils ont eu un RCE CVSS 9.9 pour ça. J'ai fait celui-ci, et j'ai un moteur de workflow strictement moins expressif que le leur. Choisissez votre compromis en connaissance de cause.

Ce que « déclaratif » signifie réellement ici

Le moteur de workflow de lodos expose cinq primitives. C'est tout :

  • http_request: méthode, template d'URL, headers, body, liste d'hôtes allowedEgress, timeoutMs
  • db_query: SELECT uniquement contre la SQLite locale, avec une liste de refus statique pour les tables vault/facturation
  • ai_call: egress codé en dur vers api.anthropic.com, noSecrets: true (transcript nettoyé avant envoi)
  • tool_call: outils MCP en lecture-seule-automatique, verrouillés par le guard, avec dégradation optionnelle
  • Le contrôle de flux: wait, if_else, loop avec des comparateurs à énumération fermée (eq, neq, gt, lt, changed-since-last-run)

C'est cette dernière ligne que je veux défendre. La plupart des moteurs de workflow que j'ai vus, n8n, Zapier, Temporal, vous laissent même écrire une expression JS dans le slot de condition. Si ce nombre est supérieur à 5, on part à gauche. L'évaluateur d'expressions est pratique pour les utilisateurs et une falaise pour les équipes sécurité. Donc on n'en a pas. Le comparateur est une énumération ; la comparaison est une donnée ; il n'y a aucun chemin entre un champ YAML édité par l'utilisateur et un appel de fonction.

On perd des choses. On ne peut pas écrire if step.result.users.filter(u => u.active).length > 5. On peut écrire if step.result.activeUserCount gt 5 et produire activeUserCount en amont dans un db_query ou un ai_call. Le calcul se déplace vers les primitives qui existent déjà ; la définition du workflow reste déclarative.

Les conditions basées sur des expressions de n8n compilent l'entrée utilisateur en code exécutable ; les comparateurs déclaratifs de lodos gardent l'entrée utilisateur comme donnée, pas comme code.

La discipline du schéma

Le YAML de workflow est parsé via un schéma Zod avec z.lazy pour la récursion (les boucles et les if_else peuvent s'imbriquer). Un z.lazy non borné, c'est un autre nom pour eval, un YAML malveillant peut faire exploser la pile ou le tas avant même qu'un handler ne s'exécute. Le schéma est donc doublement borné :

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;

Le moteur de workflow de lodos : du YAML déclaratif borné par un schéma Zod, profondeur ≤ 5, nœuds totaux ≤ 100, ≤ 16 enfants par tableau, sur cinq primitives, sans eval, sans Function, sans vm.

Trois bornes, chacune nécessaire. per-array .max(16) empêche qu'un seul bloc ne devienne un mégaoctet de branches imbriquées. total-nodes 100 borne le graphe entier (on ne peut pas faire passer de la complexité en douce sous le cap par tableau en l'étalant finement). depth 5 garde le parcours récursif à temps constant relativement à l'entrée. Aucune des trois n'est de la paranoïa ; chacune ferme une classe d'attaque par épuisement de ressources qu'un vrai utilisateur de workflow n'écrirait jamais mais qu'un YAML hostile pourrait tenter.

Pourquoi secret_value_get n'existe pas

Il n'existe aucun outil MCP dans le système dont le contrat serait donne-moi le texte en clair d'un secret. Il y a un secret_inject_and_run à six couches qui prend une référence de secret et un argv, place la valeur dans l'environnement d'un sous-processus, exécute la commande, et ne renvoie jamais la valeur à l'IA appelante. C'est tout.

Ce n'est pas une politique (« n'ajoutez pas un tel outil »). Le script de build vérifie l'absence :

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

Si un futur moi, ou un futur éditeur IA travaillant sur cette base de code, se convainc qu'il a besoin d'un outil de secret qui renvoie une valeur, le build refuse avant le merge.

INV-M5: le fil-piège de l'eval

Le contrôle correspondant pour le moteur de workflow est encore plus simple :

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

Cinquante lignes de Node, zéro dépendance, exécuté dans l'étape standard pnpm verify. Toute la classe de CVE dans laquelle vit le RCE-par-expression CVSS-9.9 de n8n est fermée par un grep.

Ce qui rend cette discipline réelle, c'est la preuve négative : le script est livré avec sa propre fausse violation. Un fichier __probe.ts jetable avec un eval( littéral dedans. La CI exécute le script deux fois, d'abord avec la sonde injectée (doit sortir en code 1), puis sans elle (doit sortir en code 0). Un détecteur de refus qui n'échoue jamais est indiscernable de l'absence de détecteur ; la preuve négative est ce qui donne du mordant à ce script.

Faire croître les fonctionnalités par restriction, pas par relâchement

La question du lecteur sceptique : est-ce que ça ne casse pas tout quand on a besoin de plus de fonctionnalités ?

Quand j'ai livré web_fetch pour l'egress web L2, je n'ai pas ajouté d'évaluateur. J'ai ajouté : une liste blanche d'hôtes restreinte + un budget d'octets par source + une réauthentification sur redirection + une protection SSRF-contre-soi-même + un firewall de rendu + une propagation du contenu « taint ». Vingt-six nouveaux cas de test, zéro nouvelle surface d'eval. Le moteur est devenu plus puissant en ajoutant des bornes, pas en ajoutant du pouvoir d'expression.

C'est possible de faire ça. La plupart des évolutions dans les outils de workflow sont interprétées comme « donner à l'utilisateur plus de surface d'eval ». On peut l'interpréter autrement, comme « donner à l'utilisateur plus de primitives bornées ». La seconde approche est plus difficile à concevoir et impossible à faire évoluer vers un CVSS 9.9.

Ce que ça coûte et pourquoi je l'ai payé

Vous abandonnez : le calcul arbitraire à l'intérieur d'une étape, les conditions par expression JS, la projection dynamique de champs, tout ce que l'utilisateur peut faire dans le {{ $json.foo.map(x => x * 2) }} de n8n. Les vrais utilisateurs de workflow ont bien besoin de ces choses, et quand c'est le cas dans lodos, ils y accèdent en écrivant une étape ai_call ou db_query de plus. La définition du workflow reste déclarative ; la surface d'eval reste vide.

Vous gagnez : un moteur de workflow où CVE-2025-68613 est structurellement impossible, où le schéma impose une consommation de ressources bornée, et où chaque refus est appliqué par un grep, pas par un docstring.


Le prochain article de la série descend d'un niveau, dans le vault. "AI sk_live'ı göremez", l'IA ne peut pas voir votre clé Stripe live, c'est l'affirmation architecturale, et le fragment de schéma qui la sous-tend est celui dont je suis le plus fier. Zero-Knowledge as Architectural Blindness explique pourquoi notre schéma Prisma ne peut littéralement pas contenir un chemin de champ en clair, et comment cela nous offre gratuitement une histoire d'audit de niveau SOC2.

workflow-enginesecuritearchitecturezero-knowledge

Fais tourner ton entreprise sur ta propre machine.

Rejoins la liste d’attente et sois prioritaire à l’ouverture de lodos.