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.
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ôtesallowedEgress,timeoutMsdb_query: SELECT uniquement contre la SQLite locale, avec une liste de refus statique pour les tables vault/facturationai_call: egress codé en dur versapi.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,loopavec 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.

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;

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.