Introduction
In the gateway-workflow-dispatcher-v2.js file, a workflow event dispatching system was quietly harboring a dangerous pattern: the execSync function from Node.js's child_process module was being used with string concatenation to build shell commands. At line 568, the code constructed a command like this:
const result = execSync(
'openclaw system event --mode now --json --text ' + JSON.stringify(eventText),
{ timeout: 15000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
);
While JSON.stringify might seem like protection, it doesn't prevent shell metacharacter injection. An attacker controlling eventText could break out of the intended command structure and execute arbitrary system commands. This is particularly dangerous in a workflow dispatcher that handles event-driven automation—compromising this component could give attackers control over downstream business processes.
The Vulnerability Explained
The vulnerable code at lines 564-571 in gateway-workflow-dispatcher-v2.js used execSync to execute the openclaw CLI tool:
try {
const result = execSync(
'openclaw system event --mode now --json --text ' + JSON.stringify(eventText),
{ timeout: 15000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
);
const parsed = JSON.parse(result.trim());
// ...
}
The critical flaw: execSync spawns a shell (/bin/sh on Unix, cmd.exe on Windows) and passes the entire string to that shell for interpretation. Even with JSON.stringify, an attacker could craft eventText containing shell metacharacters that escape the quoted context.
How the Attack Works
Consider what happens when eventText contains:
"; rm -rf /important/data; echo "
The shell sees:
openclaw system event --mode now --json --text "; rm -rf /important/data; echo "
The semicolons terminate the intended command and start new ones. The JSON.stringify actually helps the attacker by providing clean quoting boundaries to escape from.
In the gateway-workflow-dispatcher-v2.js context, this is especially severe because:
- The GatewayWorkflowDispatcherV2 class handles workflow orchestration
- Compromised event dispatch could propagate to downstream systems
- The 15-second timeout provides ample window for command execution
- The stdio: ['ignore', 'pipe', 'pipe'] configuration still allows command output capture
Real-World Impact
An attacker with control over workflow event data could:
- Exfiltrate sensitive workflow configurations
- Modify budget enforcement rules (the file imports createBudgetEnforcement)
- Pivot to the PostgreSQL database via the Pool connection
- Disrupt business operations by corrupting workflow state
The Fix
The patch makes a surgical but critical change: replacing execSync with execFileSync and restructuring how arguments are passed.
Before (Vulnerable)
const { execSync } = require('child_process');
// ...
const result = execSync(
'openclaw system event --mode now --json --text ' + JSON.stringify(eventText),
{ timeout: 15000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
);
After (Fixed)
const { execFileSync } = require('child_process');
// ...
const result = execFileSync(
'openclaw',
['system', 'event', '--mode', 'now', '--json', '--text', eventText],
{ timeout: 15000, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }
);
Why This Fixes the Vulnerability
| Aspect | execSync (Vulnerable) |
execFileSync (Fixed) |
|---|---|---|
| Shell spawned | Yes (/bin/sh or cmd.exe) |
No (direct execution) |
| Argument passing | Single string, shell-parsed | Array of strings, no shell parsing |
| Metacharacter risk | High—shell interprets everything | None—arguments passed directly to kernel |
| Injection vector | String concatenation | Not possible—array elements are separate |
The execFileSync function directly executes the binary at the specified path with the provided arguments array. There's no shell to interpret metacharacters, so even malicious input in eventText is treated as a literal string argument to openclaw.
Behavior Preservation
The fix maintains identical functionality:
- Same 15-second timeout
- Same UTF-8 encoding
- Same stdio configuration
- Same return value handling with JSON.parse(result.trim())
Only the execution mechanism changes—making it strictly safer with no functional regression.
Prevention & Best Practices
1. Prefer execFile/execFileSync Over exec/execSync
| Use Case | Recommended API |
|---|---|
| Execute command with arguments | execFile / execFileSync |
| Need shell features (wildcards, pipes) | exec with extreme caution + validation |
| Complex pipelines | Consider spawn with manual pipe handling |
2. If You Must Use Shell Execution
When shell features are unavoidable:
- Use spawn with shell: true and the args option (Node.js 5.7+)
- Apply strict allowlist validation to all inputs
- Consider shell-quote or similar libraries for escaping
3. Static Analysis Integration
The Semgrep rule javascript.lang.security.detect-child-process.detect-child-process that flagged this issue should be part of your CI pipeline:
# .github/workflows/security.yml
- uses: returntocorp/semgrep-action@v1
with:
config: >-
p/security-audit
p/owasp-top-ten
p/cwe-top-25
4. Defense in Depth
Even with safe APIs, validate inputs against expected patterns:
// Additional hardening for eventText
const VALID_EVENT_TEXT = /^[\w\s\-.,:;!?()[\]{}]{1,10000}$/;
if (!VALID_EVENT_TEXT.test(eventText)) {
throw new ValidationError('Invalid event text format');
}
Key Takeaways
-
Never use
execSyncwith string concatenation in workflow dispatchers: TheGatewayWorkflowDispatcherV2.dispatchEvent()method at line 568 was a textbook command injection vector. -
JSON.stringifyis not shell escaping: It produces valid JavaScript/JSON strings, not shell-safe strings. The shell interprets its output differently than JavaScript does. -
Array-based argument passing eliminates entire vulnerability classes:
execFileSyncwith['system', 'event', '--mode', 'now', '--json', '--text', eventText]makes injection structurally impossible. -
Proactive removal of exploit primitives matters: This fix removes a code pattern that, while not independently exploitable in isolation, could be chained with other weaknesses by automated exploit-development tooling.
-
Semgrep's
detect-child-processrule catches this pattern: Integrate this rule into your security scanning to prevent similar issues ingateway-workflow-dispatcher-v2.jsand other files.
How Orbis AppSec Detected This
| Field | Details |
|---|---|
| Source | The eventText parameter in GatewayWorkflowDispatcherV2 class methods |
| Sink | execSync() call at line 568 in gateway-workflow-dispatcher-v2.js |
| Missing control | No validation that eventText lacks shell metacharacters; use of string concatenation instead of array-based arguments |
| CWE | CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection') |
| Fix | Replaced execSync with execFileSync and refactored from string concatenation to array-based argument passing |
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The gateway-workflow-dispatcher-v2.js command injection vulnerability demonstrates how a single API choice—execSync versus execFileSync—can mean the difference between secure code and a critical vulnerability. The fix is elegant in its simplicity: by avoiding shell interpretation entirely, we eliminate an entire class of attacks without changing application behavior.
For Node.js developers, this case reinforces a fundamental principle: treat the shell as a dangerous dependency. When you don't need shell features, don't use shell-based APIs. The execFileSync pattern shown here should be your default for executing external commands—it's safer, clearer, and just as capable for the vast majority of use cases.