Author: Ester Gracia
Url: https://medium.com/@ester.gracia.10/securing-your-node-js-app-from-command-injection-e44f0a6ccaf2
Date Published: May 14, 2025
Content: Securing Your Node.js App from Command Injection
Securing Your Node.js App from Command Injection
For whatever reason, our application needs to run shell commands. We must be extra cautious, thinking twice, as executing system commands introduces the risk of command injection.
In simpler terms, command injection happens when malicious input causes our backend to run unintended (and potentially harmful) system commands. How can?
Background
In my project, I need to decrypt password-protected PDFs using QPDF. The process involved two steps:
Doing the task manually through terminal would have been easy.
# check if QPDF exist
qpdf --version
# decrypt the PDF
qpdf --password=<password> --decrypt "<input-path>" "<output-path>"
Transforming it into backend functionality, here is the straight-forward code snippet:
checkQpdfAvailability() {
// function to check if QPDF is installed
exec('qpdf --version', (error) => {
this.isQpdfAvailable = !error;
if (!this.isQpdfAvailable) {
console.warn('QPDF is not installed or not in PATH. PDF decryption will not work until qpdf is installed.');
return false;
}
});
return true;
}
async execCommand(command) {
// Check if QPDF is available before executing the command
if (!checkQpdfAvailability()) {
throw new Error(
'QPDF is not installed. Please install QPDF to decrypt PDF files.\n' +
'Windows: Install from https://qpdf.sourceforge.io/ or using Chocolatey: choco install qpdf\n' +
'Linux: sudo apt-get install qpdf\n' +
'MacOS: brew install qpdf'
);
}
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(new Error(`Failed to decrypt PDF: ${stderr.trim()}`));
} else {
resolve();
}
});
});
}
async decrypt(pdfBuffer, password) {
// Check PDF buffer exists
if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('Invalid input: Expected a Buffer.');
}
let tempDir = null;
let inputPath = null;
let outputPath = null;
try {
tempDir = path.join(os.tmpdir(), `pdf-decrypt-${crypto.randomBytes(8).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true });
inputPath = path.join(tempDir, 'encrypted.pdf');
outputPath = path.join(tempDir, 'decrypted.pdf');
fs.writeFileSync(inputPath, pdfBuffer);
const command = `qpdf --password=${password} --decrypt "${inputPath}" "${outputPath}"`;
await this.execCommand(command);
if (!fs.existsSync(outputPath)) {
throw new Error('Failed to decrypt PDF: Output file not created.');
}
const decryptedPdf = fs.readFileSync(outputPath);
return decryptedPdf;
} catch (error) {
if (error.message.toLowerCase().includes('password')) {
throw new Error('Failed to decrypt PDF: Incorrect password.');
} else if (error.message.includes('PDF header') || error.message.includes('not a PDF')) {
throw new Error('Failed to decrypt PDF: Corrupted file.');
} else {
throw error;
}
} finally {
// Function to clean up the temprary files for decryption purpose
this.cleanupFiles([inputPath, outputPath, tempDir]);
}
}
Let’s examine the security issues in the code above.
Issue 1: Unsanitized Password Input
Let’s examine our decrypt function.
async decrypt(pdfBuffer, password) {
// Check PDF buffer exists
if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('Invalid input: Expected a Buffer.');
}
let tempDir = null;
let inputPath = null;
let outputPath = null;
try {
tempDir = path.join(os.tmpdir(), `pdf-decrypt-${crypto.randomBytes(8).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true })
// ... the rest of the code
Notice that we ask for password from user to decrypt the PDF. However, user could craft a malicious password that executes unintended shell commands. Therefore, we need to add input sanitation to our implementation before proceeding to the next step.
sanitizePassword(password) {
// Ensure the input is a string
if (typeof password !== 'string') {
throw new Error('Password must be a string');
}
// Prevent denial-of-service via excessive length
const MAX_PASSWORD_LENGTH = 1024;
if (password.length > MAX_PASSWORD_LENGTH) {
throw new Error(`Password exceeds maximum length of ${MAX_PASSWORD_LENGTH} characters`);
}
// Only allow printable ASCII characters (space to ~)
// This prevents shell control characters, escape sequences, etc.
const printableAsciiRegex = /^[\x20-\x7E]*$/;
if (!printableAsciiRegex.test(password)) {
throw new Error('Password contains invalid or unsafe characters');
}
return password;
}
async decrypt(pdfBuffer, password) {
if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('Invalid input: Expected a Buffer.');
}
const sanitizedPassword = this.sanitizePassword(password);
let tempDir = null;
let inputPath = null;
// ... the rest of the code
Issue 2: PATH Manipulation
Using qpdf --version relies on system’s PATH which can be manipulated to execute malicious binary instead of the real QPDF.
checkQpdfAvailability() {
// function to check if QPDF is installed
exec('qpdf --version', (error) => {
this.isQpdfAvailable = !error;
if (!this.isQpdfAvailable) {
console.warn('QPDF is not installed or not in PATH. PDF decryption will not work until qpdf is installed.');
return false;
}
});
return true;
}
Several proposed solutions to this, such as:
In my project, the last option is the most suitable for practicality and security.
_checkQpdfInPath() {
return new Promise((resolve, reject) => {
const envPath = process.env.QPDF_PATH;
if (envPath && fs.existsSync(envPath)) {
return resolve(envPath);
}
reject(new Error('QPDF not found in PATH.'));
});
}
Further Improvement: Using spawn Instead of exec
Node.js provide several interface for dealing with child process through the module child_process. Although they might seem similar, here is a comparison between exec and spawn:
Depending on your needs, you might want to consider another child process such as execFile or fork, or even their synchronous versions.
Final Notes
Below is the final implementation.
_checkQpdfInPath() {
return new Promise((resolve, reject) => {
const envPath = process.env.QPDF_PATH;
if (envPath && fs.existsSync(envPath)) {
return resolve(envPath);
}
reject(new Error('QPDF not found in PATH.'));
});
}
_checkQpdfVersion(qpdfPath) {
return new Promise((resolve, reject) => {
const process = spawn(qpdfPath, ['--version']);
process.on('error', (error) => {
reject(error);
});
process.on('close', (code) => {
if (code !== 0) {
return reject(new Error('Failed to verify qpdf version'));
}
resolve();
});
});
}
async checkQpdfAvailability() {
if (this._qpdfAvailabilityPromise) {
return this._qpdfAvailabilityPromise;
}
this._qpdfAvailabilityPromise = new Promise((resolve) => {
this._checkQpdfInPath()
.then(qpdfPath => this._checkQpdfVersion(qpdfPath))
.then(() => resolve(true))
// eslint-disable-next-line no-unused-vars
.catch(_error => {
console.warn('QPDF is not installed or not in PATH. PDF decryption will not work until qpdf is installed.');
resolve(false);
});
});
return this._qpdfAvailabilityPromise;
}
async execCommand(command, args) {
const isAvailable = await this.checkQpdfAvailability();
if (!isAvailable) {
throw new Error(
'QPDF is not installed. Please install QPDF to decrypt PDF files.\n' +
'Windows: Install from https://qpdf.sourceforge.io/ or using Chocolatey: choco install qpdf\n' +
'Linux: sudo apt-get install qpdf\n' +
'MacOS: brew install qpdf'
);
}
return new Promise((resolve, reject) => {
const process = spawn(command, args);
let stderr = '';
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Failed to decrypt PDF: ${stderr.trim()}`));
} else {
resolve();
}
});
});
}
sanitizePassword(password) {
// Ensure the input is a string
if (typeof password !== 'string') {
throw new Error('Password must be a string');
}
// Prevent denial-of-service via excessive length
const MAX_PASSWORD_LENGTH = 1024;
if (password.length > MAX_PASSWORD_LENGTH) {
throw new Error(`Password exceeds maximum length of ${MAX_PASSWORD_LENGTH} characters`);
}
// Only allow printable ASCII characters (space to ~)
// This prevents shell control characters, escape sequences, etc.
const printableAsciiRegex = /^[\x20-\x7E]*$/;
if (!printableAsciiRegex.test(password)) {
throw new Error('Password contains invalid or unsafe characters');
}
return password;
}
async decrypt(pdfBuffer, password) {
if (!Buffer.isBuffer(pdfBuffer)) {
throw new Error('Invalid input: Expected a Buffer.');
}
const sanitizedPassword = this.sanitizePassword(password);
let tempDir = null;
let inputPath = null;
let outputPath = null;
try {
tempDir = path.join(os.tmpdir(), `pdf-decrypt-${crypto.randomBytes(8).toString('hex')}`);
fs.mkdirSync(tempDir, { recursive: true });
inputPath = path.join(tempDir, 'encrypted.pdf');
outputPath = path.join(tempDir, 'decrypted.pdf');
fs.writeFileSync(inputPath, pdfBuffer);
await this.execCommand('qpdf', [
`--password=${sanitizedPassword}`,
'--decrypt',
inputPath,
outputPath
]);
if (!fs.existsSync(outputPath)) {
throw new Error('Failed to decrypt PDF: Output file not created.');
}
const decryptedPdf = fs.readFileSync(outputPath);
return decryptedPdf;
} catch (error) {
if (error.message.toLowerCase().includes('password')) {
throw new Error('Failed to decrypt PDF: Incorrect password.');
} else if (error.message.includes('PDF header') || error.message.includes('not a PDF')) {
throw new Error('Failed to decrypt PDF: Corrupted file.');
} else {
throw error;
}
} finally {
this.cleanupFiles([inputPath, outputPath, tempDir]);
}
}
Well, who would’ve thought that digging deep into how your code runs behind the scenes could be so helpful?
Reference