Command Injection in Node.js: Secure Child Process Best Practices
Command injection in Node.js can lead to full server compromise. Learn how to stop it by moving away from shell execution and mastering secure input handling.
Last month, I spent about two days auditing a legacy service that allowed users to generate custom PDF reports. The developers had used child_process.exec to call a system utility, passing user-provided filenames directly into a shell string. It was a classic setup for disaster, and it took me roughly 1.8x longer than expected to refactor the entire pipeline because the original code was riddled with implicit shell dependencies.
Why Command Injection is a Silent Killer
Command injection occurs when your application blindly trusts user input and passes it to a system shell. In Node.js, functions like exec and execSync spawn a shell (like /bin/sh or cmd.exe) before running your command. If that command contains unsanitized input, an attacker can append their own malicious instructions using characters like ;, &&, or |.
When you're building applications that interact with the host OS, you need to prioritize command injection prevention from the start. Just like you'd use parameterized queries to stop SQL injection, you must treat system-level calls as untrusted boundaries.
The Problem with Shell Execution
Let's look at the "wrong way" to handle user input. This is what I found during my audit:
JAVASCRIPTconst { exec } = require(CE9178">'child_process'); // DANGEROUS: Do not do this function convertFile(filename) { exec(CE9178">`pdf-converter --input ${filename}`, (err, stdout) => { // ... }); }
If a user provides file.pdf; rm -rf /, the shell executes pdf-converter --input file.pdf and then immediately deletes your files. It’s that simple, and it’s that dangerous. We often think our validation (like regex or length checks) is enough, but shell syntax is notoriously complex to escape correctly.
Moving to spawn and execFile
The primary fix for node.js security when dealing with child_process is to bypass the shell entirely. Instead of exec, use spawn or execFile. These functions accept an array of arguments rather than a single command string. By doing this, Node.js passes the arguments directly to the process, bypassing the shell's interpretation of special characters.
Here is how I refactored that PDF converter:
JAVASCRIPTconst { execFile } = require(CE9178">'child_process'); // SECURE: Bypasses the shell function convertFile(filename) { // Pass arguments as an array execFile(CE9178">'pdf-converter', [CE9178">'--input', filename], (err, stdout) => { if (err) throw err; console.log(stdout); }); }
By using execFile, the string file.pdf; rm -rf / is treated as a single literal filename. The command utility will simply report that the file doesn't exist, rather than executing the malicious payload.
Best Practices for Secure Input Sanitization
Even when using spawn or execFile, you shouldn't trust the input blindly. Input sanitization is your second line of defense.
- Use Allow-lists: If you're expecting a filename, validate that it matches an expected pattern (e.g.,
^[a-zA-Z0-9._-]+$). Reject anything else. - Avoid Shell Features: If your logic requires pipes or redirections, handle them in your Node.js code rather than asking the shell to do it for you.
- Principle of Least Privilege: Run your Node.js process as a non-privileged user. If you're using containers, consider Rootless Docker to further limit the blast radius of a potential compromise.
- Validate DTOs: If the input comes from an API, ensure you're using Data Transfer Objects to enforce structure early. For more on this, I’ve written about preventing mass assignment which helps keep your data layers clean.
Frequently Asked Questions
Q: Is execFile always 100% safe?
A: It’s significantly safer than exec, but you still need to be careful about the arguments you pass. If the command utility itself has vulnerabilities (like a flag that allows reading arbitrary files), even execFile won't save you. Always ensure the binary you're calling is secure.
Q: What if I absolutely need a shell?
A: If you must use a shell, sanitize the input using a battle-tested library like shell-escape. However, honestly? I’d suggest redesigning the architecture. If you're relying on shell features, you're usually one bad update away from a security hole.
Q: How do I handle multi-tenancy securely when running system commands?
A: Ensure that every process runs in an isolated environment. If you're dealing with user-specific data, consider using AsyncLocalStorage to manage request context securely. You can learn more about managing that state in my guide on Next.js multi-tenancy.
Final Thoughts
Refactoring that legacy code was a reminder that we often take the easy path with exec because it feels faster to write. But "faster" is rarely better when it comes to system stability. Moving away from shell-based execution is a foundational step in secure coding.
Next time, I’d probably look into running the conversion utility in a dedicated, ephemeral container or a sandboxed environment rather than calling it directly from the main application process. It adds a bit of infrastructure overhead, but it offers a much stronger guarantee of isolation. Security is rarely about finding the one perfect tool; it's about building layers that make an attacker's life as difficult as possible.