Davidson Woods Family Website
Login
  • Blogs
  • Recipes
  • Stories
  • Photo Gallery
  • Articles
Home>articles>68c97669a5e018e73f53a119
  • 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.

    Command injection is an attack in which the goal is execution of arbitrary commands on the host operating system via a vulnerable application. Command injection attacks are possible when an application passes unsafe user supplied data (forms, cookies, HTTP headers etc.) to a system shell. In this attack, the attacker-supplied operating system commands are usually executed with the privileges of the vulnerable application. Command injection attacks are possible largely due to insufficient input validation.

    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:

    Checking whether QPDF is already installed.
    Decrypting the PDF using QPDF command.

    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:

    Define a list of safe, known install paths.
    Use a checksum (e.g., hash of a binary) to verify QPDF.
    Use environment variable to point directly to the QPDF binary.

    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:

    exec: Executes the command in a shell (higher risk of injection), buffers output as a string.
    spawn: Launches a new process without a shell by default (safer), streams output (more efficient for large data).

    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

    https://owasp.org/www-community/attacks/Command_Injection
    https://www.freecodecamp.org/news/node-js-child-processes-everything-you-need-to-know-e69498fe970a/
    https://www.nodejs-security.com/blog/introduction-command-injection-vulnerabilities-nodejs-javascript
    https://www.nodejs-security.com/blog/secure-javascript-coding-practices-against-command-injection-vulnerabilities
    Reply Return to List Page
  • About us
  • Contact

© by Mark Davidson

All rights reserved.