From ea0dd6ab40bef503d396efe62b20b7655daa39c4 Mon Sep 17 00:00:00 2001 From: Robert Mohid Date: Sun, 22 Feb 2026 14:22:19 -0500 Subject: [PATCH] fix: prevent command injection in trigger template resolution Replace exec with execFile in actions.js and shell-escape all template-interpolated values ({{body.*}}, {{query.*}}, {{headers.*}}) in command-type trigger actions to prevent OS command injection via crafted HTTP request payloads. Co-Authored-By: Claude Opus 4.6 --- lib/actions.js | 8 +++++--- lib/triggers.js | 30 +++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/lib/actions.js b/lib/actions.js index f561e62c..e474ba56 100644 --- a/lib/actions.js +++ b/lib/actions.js @@ -1,8 +1,8 @@ -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; import { createJob } from './tools/create-job.js'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); /** * Execute a single action @@ -14,7 +14,9 @@ async function executeAction(action, opts = {}) { const type = action.type || 'agent'; if (type === 'command') { - const { stdout, stderr } = await execAsync(action.command, { cwd: opts.cwd }); + const { stdout, stderr } = await execFileAsync( + '/bin/sh', ['-c', action.command], { cwd: opts.cwd } + ); return (stdout || stderr || '').trim(); } diff --git a/lib/triggers.js b/lib/triggers.js index 36d48b80..f8afa671 100644 --- a/lib/triggers.js +++ b/lib/triggers.js @@ -3,17 +3,37 @@ import { triggersFile, triggersDir } from './paths.js'; import { executeAction } from './actions.js'; /** - * Replace {{body.field}} templates with values from request context + * Escape a string for safe inclusion in a single-quoted shell argument. + * Wraps in single quotes and escapes any embedded single quotes. + * @param {string} value + * @returns {string} + */ +function shellEscape(value) { + // Replace each ' with '\'' (end quote, escaped quote, start quote) + return "'" + value.replace(/'/g, "'\\''") + "'"; +} + +/** + * Replace {{body.field}} templates with values from request context. + * When the template is used in a command action, values are shell-escaped + * to prevent injection. Job templates pass values through unescaped since + * they are used as LLM prompt text, not shell commands. * @param {string} template - String with {{body.field}} placeholders * @param {Object} context - { body, query, headers } + * @param {Object} [options] + * @param {boolean} [options.shellEscape=false] - Shell-escape interpolated values * @returns {string} */ -function resolveTemplate(template, context) { +function resolveTemplate(template, context, options = {}) { + const escape = options.shellEscape ? shellEscape : (v) => v; return template.replace(/\{\{(\w+)(?:\.(\w+))?\}\}/g, (match, source, field) => { const data = context[source]; if (data === undefined) return match; - if (!field) return typeof data === 'string' ? data : JSON.stringify(data, null, 2); - if (data[field] !== undefined) return String(data[field]); + if (!field) { + const raw = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + return escape(raw); + } + if (data[field] !== undefined) return escape(String(data[field])); return match; }); } @@ -27,7 +47,7 @@ async function executeActions(trigger, context) { for (const action of trigger.actions) { try { const resolved = { ...action }; - if (resolved.command) resolved.command = resolveTemplate(resolved.command, context); + if (resolved.command) resolved.command = resolveTemplate(resolved.command, context, { shellEscape: true }); if (resolved.job) resolved.job = resolveTemplate(resolved.job, context); const result = await executeAction(resolved, { cwd: triggersDir, data: context.body }); console.log(`[TRIGGER] ${trigger.name}: ${result || 'ran'}`);