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.
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.
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.
spawn and execFileThe 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.
Even when using spawn or execFile, you shouldn't trust the input blindly. Input sanitization is your second line of defense.
^[a-zA-Z0-9._-]+$). Reject anything else.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.
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.
Secure file uploads from the ground up require more than basic validation. Learn how to prevent RCE and directory traversal in your production systems.