workflow-engine
Construimos un motor de workflows que no puede tener el CVE de n8n (1/5)
n8n publicó un RCE con CVSS 9.9 a través de eval de expresiones. El motor de workflows de lodos no puede tener ese fallo, no existe ningún evaluador en ningún lugar donde inyectar código, y un grep en tiempo de build lo mantiene así.
n8n tuvo el CVE-2025-68613 el mes pasado, CVSS 9.9, RCE vía eval de expresiones. Esa no es la parte interesante.
La parte interesante es que toda esa clase de vulnerabilidad está estructuralmente ausente del motor de workflows que diseñé para lodos, no porque hagamos mejor sandboxing, no porque parcheemos más rápido, sino porque no hay ningún evaluador de expresiones en ningún punto del pipeline donde se pueda inyectar algo. El script de build lo busca con grep. Si en el futuro yo mismo añado eval, new Function o vm.runInContext a la superficie del motor de workflows, el build se pone en rojo antes de que el commit llegue a existir.
Este post es el recorrido arquitectónico: qué significa realmente "declarativo y acotado" a nivel del esquema YAML, por qué elegí perder la Turing-completitud a propósito, y el grep concreto que sostiene esa línea.
Si construyes herramientas de workflows, o cualquier sistema que tome expresiones proporcionadas por el usuario y las ejecute, vale la pena examinar honestamente el trade-off. n8n eligió uno distinto, y tuvieron un RCE con CVSS 9.9 por ello. Yo elegí este, y tengo un motor de workflows estrictamente menos expresivo que el suyo. Elige tu compromiso con conocimiento de causa.
Qué significa "declarativo" aquí en realidad
El motor de workflows de lodos expone cinco primitivas. Eso es todo:
http_request: método, plantilla de URL, headers, body, lista de hostsallowedEgress,timeoutMsdb_query: solo SELECT contra el SQLite local, con una lista de denegación estática para las tablas de vault/facturaciónai_call: egress fijo aapi.anthropic.com,noSecrets: true(la transcripción se limpia antes de enviarse)tool_call: herramientas MCP de solo lectura y auto-ejecutables, controladas por el guard, con soporte para degradación opcional- Control de flujo:
wait,if_else,loopcon comparadores de enum cerrado (eq,neq,gt,lt,changed-since-last-run)
Esa última línea es la que quiero defender. La mayoría de los motores de workflows que he visto, n8n, Zapier, Temporal, incluso te dejan escribir una expresión JS en el slot de la condición. Si este número es mayor que 5, ramifica a la izquierda. El evaluador de expresiones es cómodo para los usuarios y un precipicio para los equipos de seguridad. Así que nosotros no tenemos uno. El comparador es un enum; la comparación es dato; no existe ningún camino desde un campo YAML editado por el usuario hasta una llamada a función.
Se pierden cosas. No puedes escribir if step.result.users.filter(u => u.active).length > 5. Puedes escribir if step.result.activeUserCount gt 5 y producir activeUserCount río arriba en un db_query o un ai_call. El cómputo se traslada a las primitivas que ya existen; la definición del workflow se mantiene declarativa.

La disciplina del esquema
El YAML del workflow se parsea a través de un esquema Zod con z.lazy para la recursión (los loop y los if_else pueden anidarse). Un z.lazy sin límites es otro nombre para eval, un YAML malicioso puede reventar el stack o el heap antes de que se ejecute ningún handler. Por eso el esquema tiene doble límite:
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;

Tres límites, cada uno de ellos necesario. per-array .max(16) evita que un solo bloque se convierta en un megabyte de ramas anidadas. total-nodes 100 acota el grafo entero (no puedes colar complejidad más allá del límite por array repartiéndola en muchos arrays pequeños). depth 5 mantiene el recorrido recursivo con tiempo constante respecto a la entrada. Ninguno de los tres es paranoia; cada uno cierra una clase de ataque de recursos que un usuario real de workflows jamás escribiría, pero que un YAML hostil sí.
Por qué secret_value_get no existe
No hay ninguna herramienta MCP en el sistema cuyo contrato sea dame el texto plano de un secreto. Hay un secret_inject_and_run de seis capas que toma una referencia a un secreto y un argv, coloca el valor en el entorno de un subproceso, ejecuta el comando, y nunca devuelve el valor a la IA que lo llamó. Eso es todo.
Esto no es una política ("no añadas una herramienta así"). El script de build afirma la ausencia:
// 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 algún yo futuro, o algún editor de IA futuro trabajando en este código, se convence de que necesita una herramienta de secretos que devuelva el valor, el build lo rechaza antes de que llegue el merge.
INV-M5: el tripwire de eval
La comprobación correspondiente para el motor de workflows es todavía más 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);
}
}
}
}
Cincuenta líneas de Node, cero dependencias, se ejecuta en el paso estándar pnpm verify. Toda la clase de CVE en la que vive el RCE por expresiones de CVSS 9.9 de n8n queda cerrada con un grep.
La disciplina que hace esto real es la prueba negativa: el script se distribuye con su propia violación falsa. Un archivo __probe.ts desechable con un eval( literal dentro. El CI ejecuta el script dos veces, primero con la sonda inyectada (debe salir con 1), luego sin ella (debe salir con 0). Un detector de rechazos que nunca falla es indistinguible de no tener detector en absoluto; la prueba negativa es lo que le da mordiente a este script.
Crecimiento de funcionalidades por acotación, no por relajación
La pregunta del lector escéptico: ¿esto no se rompe cuando necesitas más funcionalidades?
Cuando lancé web_fetch para el egress web de nivel L2, no añadí un evaluador. Añadí: lista de permitidos de hosts acotada + presupuesto de bytes por fuente + reautenticación en redirecciones + protección contra SSRF-contra-uno-mismo + firewall de renderizado + propagación de contenido contaminado (tainted). Veintiséis casos de prueba nuevos, cero superficie de eval nueva. El motor se volvió más potente añadiendo límites, no añadiendo poder de expresión.
Esto se puede hacer. La mayoría del crecimiento en las herramientas de workflows se interpreta como "dale al usuario más superficie de eval". Se puede interpretar en cambio como "dale al usuario más primitivas acotadas". Esto último es más difícil de diseñar e imposible de convertir en un CVSS 9.9.
Qué cuesta esto y por qué lo pagué
Se renuncia a: cómputo arbitrario dentro de un step, condiciones con expresiones JS, proyección dinámica de campos, a todo lo que el usuario pueda hacer en el {{ $json.foo.map(x => x * 2) }} de n8n. Los usuarios reales de workflows sí recurren a estas cosas, y cuando lo hacen en lodos, recurren a ellas escribiendo un ai_call o un db_query más. La definición del workflow se mantiene declarativa; la superficie de eval se mantiene vacía.
Se obtiene: un motor de workflows en el que el CVE-2025-68613 es estructuralmente imposible, donde el esquema impone un consumo de recursos acotado, y donde cada rechazo lo impone un grep, no un comentario en la documentación.
El siguiente post de la serie baja un nivel más, hasta el vault. "AI sk_live'ı göremez", la IA no puede ver tu clave live de Stripe, es la afirmación arquitectónica, y el fragmento de esquema que la respalda es del que más orgulloso estoy. Zero-Knowledge as Architectural Blindness explica por qué nuestro esquema de Prisma literalmente no puede contener una ruta de campo en texto plano, y cómo eso nos regala la narrativa de auditoría de nivel SOC2.