Back to Blog
high SEVERITY9 min read

How command injection happens in Node.js child_process calls and how to fix it

A high-severity command injection risk was discovered in `npm/holidaytw/lib/installer.js` where the `verifyBinaryExecutes` function passed a user-influenced `binPath` argument directly to `spawnSync` without sanitization. The fix replaces `spawnSync` with `execFileSync` combined with `path.resolve()` and explicit `shell: false`, eliminating the shell interpretation attack surface. This proactive hardening raises the bar against automated exploit-chaining tools even in local CLI contexts.

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

Answer Summary

This is a command injection vulnerability (CWE-78) in Node.js, found in the `verifyBinaryExecutes` function of `npm/holidaytw/lib/installer.js`. The root cause was passing the `binPath` argument directly to `spawnSync` without path normalization or shell isolation, allowing a malicious path value to inject shell metacharacters. The fix replaces `spawnSync` with `execFileSync` using `path.resolve(binPath)` and `shell: false`, ensuring the OS executes only the resolved binary file — never a shell command string — regardless of what characters appear in the path.

Vulnerability at a Glance

cweCWE-78
fixReplaced `spawnSync` with `execFileSync(path.resolve(binPath), ..., { shell: false })`
riskAttacker-controlled binary path executes arbitrary shell commands
languageJavaScript (Node.js)
root cause`binPath` passed to `spawnSync` without normalization or shell isolation
vulnerabilityCommand Injection via child_process

How Command Injection Happens in Node.js child_process Calls and How to Fix It

Introduction

The npm/holidaytw/lib/installer.js file handles post-install binary verification — a perfectly reasonable task for an npm package that ships a native binary. Its verifyBinaryExecutes function (line 58) accepts a binPath argument and runs that binary with --version to confirm it works. Simple enough. But the original implementation handed that path directly to spawnSync with no normalization, no explicit shell isolation, and no path validation — creating a code pattern that, in the wrong context, becomes a command injection primitive.

Semgrep's javascript.lang.security.detect-child-process.detect-child-process rule flagged this immediately. Let's break down exactly what was wrong, why it matters even in a "local CLI tool," and what the fix actually does.


The Vulnerability Explained

What the Original Code Did

Here's the vulnerable function as it existed before the patch:

// BEFORE — vulnerable
const { spawnSync } = require('child_process');

function verifyBinaryExecutes(binPath, opts = {}) {
  const timeoutMs = opts.timeoutMs ?? config.VERIFY_TIMEOUT_MS;
  const result = spawnSync(binPath, ['--version'], {
    timeout: timeoutMs,
    windowsHide: true,
  });
  if (result.error) {
    throw new VerificationError(`Failed to execute ${binPath} --version: ${result.error.message}`);
  }
  if (typeof result.status !== 'number' || result.status !== 0) {
    const stderrText = result.stderr ? result.stderr.toString('utf8').trim() : '';
    throw new VerificationError(
      `Verification failed: ${binPath} --version exited with status ${result.status}${
        stderrText ? ` (${stderrText})` : ''
      }`
    );
  }
}

The specific problematic pattern is on this line:

const result = spawnSync(binPath, ['--version'], { ... });

binPath flows in from the function's caller — ultimately sourced from command-line arguments, a config file, or the npm install environment — and is passed directly to spawnSync with no transformation.

Why This Is a Problem

While spawnSync does not invoke a shell by default (unlike exec), the path value itself is still dangerous for two reasons:

  1. Path traversal sequences like ../../malicious-binary are not normalized. A caller providing a crafted path could redirect execution to an entirely different binary.

  2. The pattern is an exploit primitive. Even if today's callers are trusted, this code pattern — unsanitized external input flowing into a child process execution — is exactly what automated exploit-chaining tools look for. If another vulnerability elsewhere in the codebase (e.g., a config file injection or an argument parsing flaw) allows an attacker to influence binPath, this function becomes the execution stage of the chain.

Concrete Attack Scenario

Imagine an attacker who can write to the npm package's configuration file (perhaps via a compromised dependency or a path traversal in a different function). They set binPath to:

../../../../../../tmp/evil-binary

Without path.resolve(), spawnSync will dutifully execute whatever lives at that relative path, resolved from the current working directory at runtime — which in an npm postinstall context is often the package root, a predictable location. The --version argument is passed harmlessly alongside, but the binary being executed is now entirely attacker-controlled.

On Windows, the attack surface is broader still: certain path formats can trigger shell interpretation even through spawn-family calls depending on the runtime version and OS configuration.


The Fix

What Changed

The patch makes four targeted changes, each closing a specific gap:

1. Switch from spawnSync to execFileSync

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

// AFTER
const { execFileSync } = require('child_process');

execFileSync is semantically clearer about intent: execute this specific file, not a shell command. It also throws on non-zero exit (which simplifies error handling) rather than returning a result object that callers must manually inspect.

2. Normalize the path with path.resolve()

// BEFORE
spawnSync(binPath, ['--version'], { ... })

// AFTER
execFileSync(path.resolve(binPath), ['--version'], { ... })

path.resolve() converts the input to an absolute path, collapsing .. sequences and eliminating relative path traversal. An attacker providing ../../tmp/evil now gets /absolute/package/root/tmp/evil — still potentially wrong, but no longer a traversal gadget. Combined with the binary allowlist in resolveTarget, this closes the traversal vector.

3. Explicitly set shell: false

execFileSync(path.resolve(binPath), ['--version'], {
  timeout: timeoutMs,
  windowsHide: true,
  shell: false,          // ← explicit, not just default
  stdio: ['ignore', 'pipe', 'pipe'],
});

While execFileSync doesn't invoke a shell by default, making shell: false explicit serves as documentation and a guard against accidental future refactoring that might change the call signature.

4. Explicit stdio configuration

stdio: ['ignore', 'pipe', 'pipe'],

stdin is ignored (the child can never block waiting for input), and both stdout and stderr are piped (captured rather than leaked into npm's install log). This is a defense-in-depth measure — it prevents information leakage from the child process's output into the install environment.

Full Before/After Comparison

// ─── BEFORE ───────────────────────────────────────────────────────────────
const { spawnSync } = require('child_process');

function verifyBinaryExecutes(binPath, opts = {}) {
  const timeoutMs = opts.timeoutMs ?? config.VERIFY_TIMEOUT_MS;
  const result = spawnSync(binPath, ['--version'], {
    timeout: timeoutMs,
    windowsHide: true,
  });
  if (result.error) {
    throw new VerificationError(`Failed to execute ${binPath} --version: ${result.error.message}`);
  }
  if (typeof result.status !== 'number' || result.status !== 0) {
    const stderrText = result.stderr ? result.stderr.toString('utf8').trim() : '';
    throw new VerificationError(
      `Verification failed: ${binPath} --version exited with status ${result.status}${
        stderrText ? ` (${stderrText})` : ''
      }`
    );
  }
}

// ─── AFTER ────────────────────────────────────────────────────────────────
const { execFileSync } = require('child_process');

function verifyBinaryExecutes(binPath, opts = {}) {
  const timeoutMs = opts.timeoutMs ?? config.VERIFY_TIMEOUT_MS;
  let stdout;
  try {
    stdout = execFileSync(path.resolve(binPath), ['--version'], {
      timeout: timeoutMs,
      windowsHide: true,
      shell: false,
      stdio: ['ignore', 'pipe', 'pipe'],
    });
  } catch (err) {
    if (typeof err.status === 'number') {
      const stderrText = err.stderr ? err.stderr.toString('utf8').trim() : '';
      throw new VerificationError(
        `Verification failed: ${binPath} --version exited with status ${err.status}${
          stderrText ? ` (${stderrText})` : ''
        }`
      );
    }
    // spawn failures (ENOENT/EACCES) or timeouts (signal kill, status is null)
    throw new VerificationError(`Failed to execute ${binPath} --version: ${err.message}`);
  }
}

The error handling is also improved: execFileSync throws a single error object for all failure modes (spawn failure, timeout, non-zero exit), and the catch block now correctly distinguishes between a non-zero exit (where err.status is a number) and a spawn/timeout failure (where err.status is null).


Prevention & Best Practices

1. Prefer execFileSync / execFile Over exec and spawnSync for File Execution

When you need to run a specific binary (not a shell command), use execFileSync or execFile. These functions treat the first argument as a file path, not a shell command string, and never invoke /bin/sh regardless of what characters appear in the path.

// ✅ Safe — executes the file directly
execFileSync('/usr/local/bin/mytool', ['--version'], { shell: false });

// ⚠️ Risky — shell: true allows metacharacter injection
exec(`${binPath} --version`, callback);

2. Always Normalize Paths Before Execution

// ✅ Normalize before use
const safePath = path.resolve(binPath);
execFileSync(safePath, args, { shell: false });

path.resolve() eliminates .. traversal sequences and converts relative paths to absolute ones, anchored to the current working directory at call time.

3. Validate Against an Allowlist Where Possible

If the set of valid binaries is known at install time (as it is here, via resolveTarget in platformMatrix), validate binPath against that known-good value before executing:

const expectedPath = resolveTarget(platform, arch);
if (path.resolve(binPath) !== path.resolve(expectedPath)) {
  throw new Error('Unexpected binary path');
}

4. Explicitly Set shell: false

Even when using APIs that don't invoke a shell by default, set shell: false explicitly. This makes your intent clear to future maintainers and prevents accidental regressions during refactoring.

5. Run Semgrep in CI

The rule that caught this — javascript.lang.security.detect-child-process.detect-child-process — is part of Semgrep's default JavaScript ruleset. Adding Semgrep to your CI pipeline catches these patterns before they reach production:

# .github/workflows/semgrep.yml
- name: Run Semgrep
  uses: returntocorp/semgrep-action@v1
  with:
    config: p/javascript

Relevant Standards

  • CWE-78: Improper Neutralization of Special Elements used in an OS Command (OS Command Injection)
  • OWASP A03:2021: Injection — command injection is a top-tier injection risk
  • Node.js Security Best Practices: The official Node.js docs recommend execFile/execFileSync over exec when shell features are not needed

Key Takeaways

  • verifyBinaryExecutes in installer.js was passing binPath directly to spawnSync without any path normalization — a pattern that enables path traversal and exploit chaining even without shell expansion.
  • Switching to execFileSync with path.resolve() and shell: false closes three distinct attack vectors in a single function: path traversal, accidental shell invocation, and ambiguous error handling.
  • "Local CLI tool" does not mean "safe from injection." npm postinstall scripts run in environments where config files, environment variables, and arguments can be influenced by other packages in the dependency tree.
  • The stdio: ['ignore', 'pipe', 'pipe'] addition is not cosmetic — it prevents the child process from blocking on stdin and stops its output from leaking into the npm install log, both of which are defense-in-depth improvements.
  • Semgrep's detect-child-process rule is a reliable signal — when it fires on a function-argument path, treat it as a real finding and resolve the path before execution.

How Orbis AppSec Detected This

  • Source: The binPath parameter of verifyBinaryExecutes(binPath, opts) — ultimately sourced from command-line arguments or install-time configuration passed through resolveTarget.
  • Sink: spawnSync(binPath, ['--version'], { ... }) at npm/holidaytw/lib/installer.js:58 — the unsanitized path flows directly into a child process execution call.
  • Missing control: No path.resolve() normalization, no explicit shell: false declaration, and no validation of binPath against the expected resolved binary path before execution.
  • CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command (OS Command Injection).
  • Fix: Replaced spawnSync(binPath, ...) with execFileSync(path.resolve(binPath), ..., { shell: false, stdio: ['ignore', 'pipe', 'pipe'] }), eliminating path traversal and shell injection vectors in a single change.

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 holidaytw installer vulnerability is a textbook example of why "it's just a local tool" is not a sufficient security argument. The verifyBinaryExecutes function's unsanitized binPath argument created a path traversal and command injection primitive that could be chained with other weaknesses in an automated attack. The fix — four targeted lines replacing spawnSync with execFileSync, adding path.resolve(), and making shell: false explicit — eliminates the attack surface without changing any observable behavior for legitimate inputs.

The broader lesson: every time you pass a function argument into a child_process call, ask yourself three questions: Is this path normalized? Is shell invocation explicitly disabled? And is this value validated against what I actually expect? If the answer to any of those is "no" or "I'm not sure," you have a finding worth fixing.


References

Frequently Asked Questions

What is command injection in Node.js child_process?

Command injection occurs when user-controlled input is passed to a child process execution function (like `spawnSync` or `exec`) in a way that allows shell metacharacters to alter the intended command, executing arbitrary OS commands.

How do you prevent command injection in Node.js?

Use `execFileSync` or `spawnSync` with `shell: false` (the default for spawn, but explicit is safer), always resolve paths with `path.resolve()`, and validate inputs against an allowlist before passing them to any child_process function.

What CWE is command injection?

Command injection maps to CWE-78: Improper Neutralization of Special Elements used in an OS Command.

Is using spawnSync instead of exec enough to prevent command injection?

Not always. `spawnSync` with a direct path argument avoids shell expansion by default, but switching to `execFileSync` with `shell: false` and `path.resolve()` makes the intent explicit, prevents accidental shell invocation, and normalizes path traversal sequences — providing defense in depth.

Can static analysis detect command injection in Node.js?

Yes. Tools like Semgrep (which flagged this exact issue with rule `javascript.lang.security.detect-child-process.detect-child-process`) and ESLint security plugins can identify dangerous `child_process` call patterns involving function arguments.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #30

Related Articles

high

How Command Injection Happens in Node.js Child Process Calls and How to Fix It

A high-severity command injection vulnerability was discovered in `bump-changed-extensions.js` where the `execSync()` function was called with unsanitized input, potentially allowing attackers to execute arbitrary commands. The fix replaces the vulnerable `execSync()` pattern with `spawnSync()` using an argument array, eliminating shell interpolation entirely and preventing command injection attacks.

high

How Shell Injection via os.system() happens in Python and how to fix it

A shell injection vulnerability in TensorFlow's DELF dataset download script allowed attackers who controlled the `data_dir` parameter to execute arbitrary shell commands by injecting metacharacters into `os.system()` calls. The fix replaces all four `os.system()` invocations with `subprocess.run()` using argument lists, eliminating shell interpretation entirely. This change closes a high-severity code execution path in production ML infrastructure.

critical

How Command Injection happens in Node.js shell-quote and how to fix it

CVE-2026-9277 is a critical command injection vulnerability in the `shell-quote` npm package (versions prior to 1.8.4) caused by unescaped line terminators that allow attackers to inject and execute arbitrary shell commands. The fix pins `shell-quote` to `>=1.8.4` via a `pnpm.overrides` entry, ensuring every transitive consumer in the dependency tree receives the patched version. Any Node.js project that processes user-influenced input through `shell-quote` and has not yet upgraded is at risk of

critical

How Remote Code Execution via Security Fix Bypass happens in Node.js and how to fix it

CVE-2026-28292 is a critical Remote Code Execution vulnerability in the `simple-git` Node.js library that allowed attackers to bypass previously applied security fixes. Applications using `simple-git` versions below 3.32.3 remained exposed even after earlier patches, and upgrading to 3.32.3 — which introduced hardened argument parsing via new `@simple-git/argv-parser` and `@simple-git/args-pathspec` sub-packages — closes the bypass. This fix is especially urgent because the vulnerability affects

critical

How Command Injection happens in Node.js shell-quote and how to fix it

A critical command injection vulnerability (CVE-2026-9277) was discovered in shell-quote 1.8.3, where unescaped line terminators in parsed shell arguments could allow attackers to inject and execute arbitrary commands. The fix upgrades shell-quote to version 1.8.4 and pins the resolution in both `package.json` and `yarn.lock` to ensure the patched version is used across the entire dependency tree. Because this package is used in a production web application that processes user-influenced input,

high

How Denial of Service via Exponential-Time Complexity Happens in Node.js Dependencies and How to Fix It

A high-severity Denial of Service vulnerability (CVE-2026-13149) was discovered in the brace-expansion npm package, where maliciously crafted input could trigger exponential-time complexity and crash Node.js applications. The fix upgrades brace-expansion from version 5.0.6 to 5.0.9 using npm overrides to ensure all nested dependencies receive the patched version.