CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection') - JavaScript
Overview
In JavaScript/TypeScript applications, CWE-95 vulnerabilities occur when untrusted input is passed to dynamic code execution functions like eval(), Function(), browser setTimeout()/setInterval() with string arguments, new Function(), node:vm, or module loading mechanisms like require() in Node.js. Untrusted input can originate from HTTP requests, external APIs, databases, files, message queues, or any source outside the application's control. These constructs are common enough that the sink often turns up in code that was never meant to compile anything, such as a calculator or a plugin loader.
Primary Defence: Never use eval() or the Function() constructor with user input; use safe alternatives like JSON.parse() for data, expression parsers with restricted grammars for formulas, static imports or controlled dynamic imports with allowlists, and browser Content Security Policy (CSP) without 'unsafe-eval'. For Node.js, do not rely on node:vm as a security boundary; if arbitrary user code is unavoidable, isolate it outside the main process with OS/container controls and strict resource limits.
JavaScript applications are vulnerable when evaluating untrusted input for calculations or business logic, using eval() for JSON parsing, dynamically loading modules based on untrusted input, using template literals without proper escaping, evaluating code in vm module without proper sandboxing, or using unsafe deserialization in Node.js. Both browser and server-side JavaScript environments can be compromised through eval injection.
Common scenarios include: calculator applications using eval() for expression evaluation, configuration systems that evaluate JavaScript code, dynamic module loading in Node.js applications, server-side rendering with unsafe template evaluation, and WebSocket or API endpoints that execute untrusted code. Modern frameworks like React, Vue, and Angular provide built-in protections, but developers can still introduce vulnerabilities through direct use of eval-like constructs.
This guidance demonstrates how to eliminate eval injection in JavaScript/TypeScript by replacing eval() with safe expression parsers, using JSON for data serialization, implementing controlled module loading, and employing Content Security Policy (CSP) to prevent eval execution.
Common Vulnerable Patterns
Direct eval() on Untrusted Input
// VULNERABLE - Direct evaluation of untrusted input
const express = require('express');
const app = express();
app.use(express.json());
app.post('/calculate', (req, res) => {
const expression = req.body.expression;
// CRITICAL VULNERABILITY - eval executes arbitrary code
const result = eval(expression);
res.json({ result });
});
// Attack examples:
// {"expression": "require('child_process').execSync('whoami').toString()"}
// {"expression": "require('fs').readFileSync('/etc/passwd', 'utf8')"}
// {"expression": "process.exit(0)"} // Crashes server
// {"expression": "global.SECRET_KEY"} // Steals secrets
// All execute with full Node.js privileges!
Why this is vulnerable: eval() runs with the caller's scope, so in a CommonJS module require is a local binding the evaluated string can reach - which is why the first attack needs nothing more exotic than the module's own imports. The language has no privilege boundary to escape; the reachable set is whatever the host exposes, and in Node that is the entire process, including process.env, the filesystem and the network.
Nothing here is specific to a calculator. Any route that reaches eval() with request data grants execution, so the severity does not depend on how narrow the intended input was.
Function Constructor with Untrusted Input
// VULNERABLE - Function constructor is equivalent to eval
app.post('/run-function', (req, res) => {
const code = req.body.code;
// CRITICAL VULNERABILITY - Function() executes arbitrary code
const fn = new Function('x', 'y', code);
const result = fn(5, 3);
res.json({ result });
});
// Attack example - note that a bare require() would NOT work here. The body
// runs in global scope, where the module-local require is not bound, so the
// payload walks the constructor chain to the loader instead:
// {"code": "return this.constructor.constructor('return process')().getBuiltinModule('child_process').execSync('ls -la').toString()"}
// Executes system commands! process.getBuiltinModule arrived in Node 22.3.0
// and 20.16.0; on an older runtime the CommonJS form below does the same job:
// {"code": "return this.constructor.constructor('return process')().mainModule.require('child_process').execSync('ls -la').toString()"}
Why this is vulnerable: new Function() is frequently offered as the safer alternative to eval(), on the grounds that the body does not see the enclosing scope - only the global one. The first half is true and the conclusion does not follow. Globals are enough: this.constructor.constructor('return process')() walks from any object back to the Function constructor and out to process, from where process.getBuiltinModule('child_process') hands back the same module require would have.
Which of the two payloads applies depends on the runtime, and neither gap protects anything. process.getBuiltinModule was added in Node 22.3.0 and backported to 20.16.0, so it is absent on Node 18 - the baseline these examples otherwise assume. process.mainModule.require covers those older runtimes but only under CommonJS: measured on Node 24.3.0 it executes the command in a .js file and mainModule is undefined in a .mjs one. Between them every supported Node reaches child_process from a new Function() body.
Treat the two as equivalent. The difference in scoping changes which payload works, not whether one does.
Browser setTimeout/setInterval with String Arguments
// VULNERABLE in browsers - string arguments to setTimeout are evaluated
function scheduleUserTask(task, delay = 1000) {
// task came from a query parameter, message, or other untrusted source
// CRITICAL VULNERABILITY - compiles and executes a string
window.setTimeout(task, delay);
}
// Attack example:
// scheduleUserTask("fetch('/api/admin', {method: 'DELETE'})", 100)
// Executes arbitrary script in the page context.
Why this is vulnerable: Node.js timers require the callback to be a function and throw for non-function callbacks. The string-timer issue applies to browser timer APIs and cross-environment code that may run in a browser.
Dynamic require() in Node.js
// VULNERABLE - Dynamic module loading based on untrusted input
app.post('/load-plugin', (req, res) => {
const pluginName = req.body.plugin;
// CRITICAL VULNERABILITY - arbitrary module loading
const plugin = require(pluginName);
const result = plugin.execute();
res.json({ result });
});
// Attack examples:
// {"plugin": "child_process"} // Load child_process module
// {"plugin": "/etc/passwd"} // SyntaxError - not valid JavaScript
// {"plugin": "../../../package.json"} // Directory traversal
// Attacker gains access to any Node.js module or file!
Why this is vulnerable: require() executes a module's top-level code as part of loading it, so resolution and execution are the same step and there is no point between them at which to check anything. The argument is resolved against the built-in module list first and then against the filesystem, so a bare child_process gets a core module while a relative or absolute path gets any file the process can read.
Be precise about what each payload does, because it changes the severity. require() of a .json file returns its parsed contents, which is a genuine read primitive for configuration and credentials. require() of a file that is not valid JavaScript throws a SyntaxError rather than disclosing it - verified on Node 24: requiring a passwd-shaped file reports Unexpected token ':' and nothing else. The serious cases are the built-in modules and any .js an attacker can place, not arbitrary file reads.
VM Module Without Proper Sandboxing
// VULNERABLE - vm.runInThisContext allows escaping
const vm = require('vm');
app.post('/run-script', (req, res) => {
const script = req.body.script;
// CRITICAL VULNERABILITY - runs in current context
const result = vm.runInThisContext(script);
res.json({ result });
});
// Even vm.runInNewContext can be escaped:
app.post('/run-isolated', (req, res) => {
const script = req.body.script;
// STILL VULNERABLE - sandbox can be escaped
const sandbox = { result: null };
vm.runInNewContext(script, sandbox);
res.json({ result: sandbox.result });
});
// Attack: Escape sandbox
// {"script": "this.constructor.constructor('return process')().exit()"}
// Gains access to process object and crashes server!
Why this is vulnerable: Node's own documentation states it plainly: the vm module is not a security mechanism and must not be used to run untrusted code. runInThisContext shares the current global object, so it is eval() with extra syntax. runInNewContext does create a fresh global, which is why it looks like the fix.
It is not, and the escape shown is the reason. Objects handed into the sandbox carry prototypes from the calling realm, so this.constructor.constructor climbs back out to the parent's Function and from there to process. Real isolation needs a separate process or a purpose-built runtime such as isolated-vm, where the boundary is enforced below the JavaScript object graph.
Client-Side eval in React/Vue
// VULNERABLE - Client-side eval in React component
import React, { useState } from 'react';
function Calculator() {
const [expression, setExpression] = useState('');
const [result, setResult] = useState(null);
const calculate = () => {
// CRITICAL VULNERABILITY - eval in browser
try {
const res = eval(expression);
setResult(res);
} catch (e) {
setResult('Error');
}
};
return (
<div>
<input
value={expression}
onChange={(e) => setExpression(e.target.value)}
/>
<button onClick={calculate}>Calculate</button>
<p>Result: {result}</p>
</div>
);
}
// Attack in browser:
// User types: document.cookie
// Steals session cookies!
//
// Or: fetch('/api/admin', {method:'DELETE'})
// Performs unauthorized actions!
Why this is vulnerable: This one is often waved through on the grounds that the user is only attacking their own browser, and that reasoning holds exactly as long as the expression cannot come from anywhere else. It usually can: a value restored from a query string, a shared link, a saved workspace or another user's comment reaches the same eval(), and then it executes with the victim's session, cookies and any token in memory.
That makes it cross-site scripting reached through a different door, so the same defence applies - the value must never become code. A calculator needs an expression parser, not the language runtime.
Secure Patterns
Using math.js with the Parser Entry Points Disabled
// SECURE - a formula parser instead of the JavaScript compiler
const express = require('express');
const { create, all } = require('mathjs');
const app = express();
app.use(express.json());
// Configure math.js and disable high-risk parser functions.
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 });
app.post('/calculate', (req, res) => {
const expression = req.body.expression;
// Validate input
if (!expression || expression.length > 200) {
return res.status(400).json({ error: 'Invalid expression' });
}
try {
// Uses the saved evaluator after disabling recursive parser entry points.
const result = limitedEvaluate(expression);
res.json({ result });
} catch (error) {
res.status(400).json({ error: 'Invalid expression' });
}
});
// Safe expressions (measured on mathjs 15.2.0):
// "2 + 2 * 3" → 8
// "sqrt(16) + pow(2, 3)" → 12
// Safely blocks - note these are name-resolution failures inside the formula
// language, not JavaScript syntax errors. Nothing is compiled either way:
// "require('fs')" → Error: Undefined function require
// "global.process" → Error: Undefined symbol global
Why this works
Using an established mathematical expression library reduces eval injection risk because the library parses a formula language instead of executing JavaScript source. require and global above are not blocked by a filter - they are simply names the formula language has never heard of, and nothing is handed to a JavaScript compiler at any point.
It is still an arbitrary-expression evaluator, so treat it as part of the attack surface. The math.import block is the load-bearing part of this example, not decoration: evaluate and parse let an expression re-enter the parser, import and createUnit let it extend the language, and simplify, derivative and resolve are further entry points into symbolic evaluation. Disable the ones the feature does not need, keep the dependency current, and limit expression length, complexity, CPU and memory. If formulas run server-side, consider evaluating them in a worker process that can be killed on timeout.
Check that the parser you pick is still maintained, because an expression evaluator is exactly the kind of dependency where that matters. expr-eval is the cautionary example and is still widely recommended: its last release is 2.0.2 from September 2019, and it carries two open high-severity GitHub advisories - GHSA-8gw3-rxh4-v6jx (prototype pollution) and GHSA-jc85-fpwf-qm7x (no restriction on functions passed to evaluate) - both marked no fix available. npm audit reports it on install. Neither advisory is reachable from a bare parse(expr).evaluate() with no scope, which is how it is usually shown, but both become reachable through the scope argument you would add the moment the feature needs variables. That is the wrong direction for a dependency to age in.
Prefer a maintained parser over hand-written parsing code when its grammar matches the feature. A hand-written evaluator looks like it has a smaller attack surface, but arithmetic parsing has more edge cases than it appears to - operator precedence, associativity, unary minus, nested calls - and the usual failure is not an injection but silently wrong answers on inputs the author never tried.
For applications migrating from eval(), a formula parser provides equivalent functionality for mathematical expressions without exposing the full JavaScript language. If your use case extends beyond pure mathematics to include variables or custom functions, expose only reviewed functions and immutable data through the library's supported scope APIs.
Operation Mapping for Business Logic
// SECURE - Explicit operation mapping instead of dynamic execution
const express = require('express');
const app = express();
app.use(express.json());
class SafeOperationHandler {
constructor() {
// Explicit allowlist of safe operations
this.operations = {
'add': (a, b) => Number(a) + Number(b),
'subtract': (a, b) => Number(a) - Number(b),
'multiply': (a, b) => Number(a) * Number(b),
'divide': (a, b) => {
const divisor = Number(b);
if (divisor === 0) {
throw new Error('Division by zero');
}
return Number(a) / divisor;
},
'power': (a, b) => {
const exp = Number(b);
if (Math.abs(exp) > 100) {
throw new Error('Exponent too large');
}
return Math.pow(Number(a), exp);
},
'percentage': (value, percent) => {
return Number(value) * (Number(percent) / 100);
}
};
}
execute(operation, ...args) {
// Validate operation is in allowlist
if (!this.operations.hasOwnProperty(operation)) {
throw new Error(`Invalid operation: ${operation}`);
}
// Validate all arguments are numbers. Coercion is not a type check:
// Number(null), Number(''), Number([]) and Number(true) are all 0 or 1,
// so an isNaN(Number(arg)) test lets "a": null through as zero. JSON
// numbers arrive as numbers, so require that.
for (const arg of args) {
if (typeof arg !== 'number' || !Number.isFinite(arg)) {
throw new Error('Invalid numeric argument');
}
}
// Execute allowlisted operation
const fn = this.operations[operation];
return fn(...args);
}
}
app.post('/calculate', (req, res) => {
const { operation, a, b } = req.body;
try {
const handler = new SafeOperationHandler();
const result = handler.execute(operation, a, b);
res.json({ result });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// Usage:
// POST /calculate {"operation": "add", "a": 5, "b": 3} → 8
// POST /calculate {"operation": "multiply", "a": 4, "b": 7} → 28
// Safely rejects:
// POST /calculate {"operation": "exec", ...} → "Invalid operation"
Why this works
Dynamic code execution is replaced by a static mapping of operation names to predefined functions. The operation name is a dictionary key rather than code: it has to match one of the explicitly registered operations, and anything else fails the check and throws. No code path hands user input to a JavaScript compiler.
The type check rejects anything that is not already a finite number, which is stricter than it looks and deliberately so - isNaN(Number(arg)) is the usual shape here and it accepts null, '', true and [], all of which coerce to 0 or 1 and compute an answer the caller never asked for. Bounds checking on power keeps an attacker from asking for an astronomically large result, though note that JavaScript numbers are IEEE 754 doubles: they saturate to Infinity rather than growing without bound, so the exponent cap here is about answer quality more than resource exhaustion. A language with arbitrary-precision integers needs that cap for real - see the Python page's AST evaluator. Each operation function carries its own validation, such as the division-by-zero check, which keeps each one auditable on its own.
Where eval() hands over the whole language, this exposes exactly the operations the feature needs. It is also cheaper, because there is no parsing or compilation - an object lookup and a call. It suits business logic where the user picks from a fixed set of operations and supplies the parameters; where the user needs to type a formula instead, use the math.js pattern above.
Controlled Module Loading with Allowlist
// SECURE - Plugin system with allowlist
const express = require('express');
const path = require('path');
const app = express();
app.use(express.json());
class SafePluginLoader {
constructor() {
// Explicit allowlist of approved plugins
this.allowedPlugins = {
'dataValidator': './plugins/dataValidator',
'reportGenerator': './plugins/reportGenerator',
'emailSender': './plugins/emailSender'
};
// Cache loaded plugins
this.pluginCache = new Map();
}
loadPlugin(pluginName) {
// Validate plugin is in allowlist
const pluginPath = this.allowedPlugins[pluginName];
if (!pluginPath) {
throw new Error(`Plugin not allowed: ${pluginName}`);
}
// Check cache
if (this.pluginCache.has(pluginName)) {
return this.pluginCache.get(pluginName);
}
// Load only the allowlisted module
const plugin = require(pluginPath);
// Validate plugin interface
if (typeof plugin.execute !== 'function') {
throw new Error('Plugin missing execute method');
}
// Cache and return
this.pluginCache.set(pluginName, plugin);
return plugin;
}
executePlugin(pluginName, parameters) {
// Validate parameters are safe types
this.validateParameters(parameters);
// Load and execute
const plugin = this.loadPlugin(pluginName);
return plugin.execute(parameters);
}
validateParameters(params) {
if (params === null || params === undefined) {
return;
}
if (typeof params !== 'object') {
throw new Error('Parameters must be an object');
}
// Check all values are safe types
for (const [key, value] of Object.entries(params)) {
const type = typeof value;
if (type !== 'string' && type !== 'number' &&
type !== 'boolean' && value !== null) {
throw new Error(`Unsafe parameter type: ${type}`);
}
}
}
}
app.post('/run-plugin', (req, res) => {
const { pluginName, parameters } = req.body;
try {
const loader = new SafePluginLoader();
const result = loader.executePlugin(pluginName, parameters);
res.json({ result });
} catch (error) {
// The catch covers require() and the plugin's own execute(), not just the
// validators. A missing module answers with the absolute require stack.
console.warn('plugin request rejected', { reason: error.message });
res.status(400).json({ error: 'Plugin request rejected' });
}
});
// Usage:
// POST /run-plugin {
// "pluginName": "dataValidator",
// "parameters": {"data": "test"}
// }
// Safely rejects:
// POST /run-plugin {"pluginName": "child_process"} → "Plugin not allowed"
// POST /run-plugin {"pluginName": "../../../etc/passwd"} → "Plugin not allowed"
Why this works
An explicit allowlist fixes which modules can be loaded. A plugin name from the request selects one of a few module paths that developers have vetted, and those paths are constants in the source rather than runtime values, so 'child_process' and 'fs' are unreachable: only the entries registered in allowedPlugins can be required.
The interface check catches an allowlisted module that no longer exports what the caller expects - note that it runs after require, so it cannot prevent a bad module's top-level code from running; only the allowlist can do that. Parameter validation restricts plugin inputs to primitives, which matters because the plugin, not the loader, is where those values get used.
The handler answers with a fixed string rather than error.message, because the catch is wider than the validation. require() on a module that is missing from the deployment reports Cannot find module './plugins/emailSender' followed by a Require stack: of absolute paths - measured on Node 24.3.0 - and plugin.execute() can throw anything at all, since it is the plugin's code and not the loader's. Log the message and return a fixed one.
The pluginCache is a performance optimisation and nothing more. It is worth saying plainly, because the opposite is often assumed: Node caches resolved modules in require.cache, so a module's top-level code runs once per process whether the loader keeps its own map or not - measured, three require() calls on the same fresh module print its top-level output once. What the cache saves is the resolution and the export lookup.
Compare this to sanitizing a user-supplied module path. The allowlist is the stronger control because the untrusted string is only ever a key: it never reaches require, so there is no path syntax to get wrong and no traversal to normalise. That is not the same as saying nothing can go wrong - an allowlisted plugin is still code you are choosing to run, and the surface is now that plugin's execute() and whatever its module pulls in at load time. What the allowlist removes is the attacker's choice of which module, which is the part this CWE is about. Keep the allowlist itself in the source or in configuration the application trusts; building it from the same request that names the plugin gives the whole thing back.
Safe JSON Parsing (Never eval for JSON)
// SECURE - Always use JSON.parse, never eval
const express = require('express');
const app = express();
app.post('/parse-data', express.text(), (req, res) => {
const jsonString = req.body;
try {
// CORRECT - Use JSON.parse
const data = JSON.parse(jsonString);
// Validate structure
if (typeof data !== 'object' || data === null) {
return res.status(400).json({ error: 'Invalid data structure' });
}
res.json({ parsed: data });
} catch (error) {
res.status(400).json({ error: 'Invalid JSON' });
}
});
// NEVER do this for JSON parsing:
// const data = eval('(' + jsonString + ')'); // DANGEROUS!
// JSON.parse is safe because:
// - It only parses data, never executes code
// - Cannot access JavaScript runtime
// - Throws error on invalid JSON
// - No code injection possible
Why this works:
JSON.parse() is safe from code injection because JSON is a data format with no mechanism for encoding executable code. Unlike JavaScript object literals which can contain function expressions, getters, setters, and other executable constructs, JSON supports only six data types: objects, arrays, strings, numbers, booleans, and null. The JSON.parse() function is implemented in native code (C++ in Node.js/V8) and never invokes the JavaScript interpreter to execute code - it only constructs data structures.
Historically, developers sometimes used eval() to parse JSON because it could handle JavaScript object literal syntax including single quotes and unquoted keys. This was never safe, even with the wrapping parentheses, because a string that claims to be JSON can carry any JavaScript at all. JSON.parse() eliminates this risk entirely by enforcing the JSON specification - any input containing JavaScript code is invalid JSON and triggers a parse error. There is no lenient mode to fall out of: unquoted keys, single-quoted strings, comments, trailing commas and undefined are all rejected outright.
Use JSON.parse() for every JSON payload, whether or not the source is trusted. It is faster than eval() because the parsing is native, and malformed input produces a parse error where eval() would have executed it. If you need relaxed JSON syntax (comments, unquoted keys), use a library built for it such as json5, never eval(). For serialization, JSON.stringify() is equally safe, though circular references throw by default.
Content Security Policy (CSP) for Browser Protection
// SECURE - Implement CSP to block eval in browser
const express = require('express');
const helmet = require('helmet');
const app = express();
// Configure helmet with strict CSP
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
// DON'T use 'unsafe-eval' or 'unsafe-inline'
// ✅ Use nonces or hashes for inline scripts
],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
})
);
// Serve React/Vue app with CSP
app.get('/', (req, res) => {
res.send(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Secure App</title>
<!-- CSP prevents eval() from executing -->
</head>
<body>
<div id="root"></div>
<script src="/app.js"></script>
</body>
</html>
`);
});
// Security mechanisms:
// - CSP blocks eval(), Function(), setTimeout with strings
// - Prevents inline script execution
// - Requires script sources to be allowlisted
// - Provides defense-in-depth
// With CSP, even if eval() is called, browser blocks it:
// eval('alert(1)') → CSP violation, blocked by browser
Why this works
Content Security Policy provides a browser-level defense against eval injection by instructing the browser to refuse executing certain types of code regardless of what the JavaScript tries to do. When CSP is configured without 'unsafe-eval', the browser's JavaScript engine will throw an error if code attempts to call eval(), Function(), setTimeout() with strings, or setInterval() with strings. This enforcement happens at the browser level, not in application code, so it remains useful even if a code path accidentally reaches an eval-like API.
CSP works by allowlisting trusted sources for each resource type. Excluding 'unsafe-eval' from script-src tells the browser that dynamic code evaluation is never legitimate in your application, so an injection point nobody knew about is blocked at the eval() call rather than at review time. The policy is in force before any of your JavaScript runs. Browsers also report violations, which turns a blocked eval() into a signal you can monitor.
In a single-page application the eval() call is often not yours: you may have avoided it throughout your own code and still ship a third-party library that compiles strings. CSP covers that code too, because the policy applies to the page rather than to a module. When implementing CSP, avoid the 'unsafe-inline' and 'unsafe-eval' directives - use nonces or hashes for inline scripts instead. For a legacy application, start in report-only mode to find violations without breaking functionality, then refactor the code that trips it. CSP does nothing for server-side eval injection in Node.js; it covers the browser half.
Safe Template Rendering
// SECURE - Use template engines with auto-escaping
const express = require('express');
const Handlebars = require('handlebars');
const app = express();
app.use(express.json());
// Handlebars with auto-escaping
app.post('/render-message', (req, res) => {
const { title, content, author } = req.body;
// Pre-defined template (NOT untrusted input!)
const templateString = `
<div class="message">
<h2>{{title}}</h2>
<p>{{content}}</p>
<small>From: {{author}}</small>
</div>
`;
// Compile template
const template = Handlebars.compile(templateString);
// Validate context values
const context = {
title: String(title || ''),
content: String(content || ''),
author: String(author || '')
};
// Render with user data (auto-escaped)
const html = template(context);
res.json({ html });
});
// Safe usage:
// POST /render-message {
// "title": "Hello",
// "content": "This is a message",
// "author": "John"
// }
// Safely handles:
// POST /render-message {
// "content": "<script>alert(1)</script>"
// }
// → Script is escaped as text, not executed
Why this works
Template structure is separated from user data: templates come from trusted sources (developers, configuration files), and users supply only the values that fill them. Handlebars and similar engines interpolate those values without executing them - when you render "{{title}}", Handlebars inserts the string value of title rather than evaluating it as JavaScript. This architectural separation means attackers cannot inject template directives through data values.
The auto-escaping feature provides defense-in-depth against related vulnerabilities. If a user provides data containing HTML or script tags, Handlebars converts them to HTML entities ("<script>" instead of "<script>"), preventing XSS attacks. Rendering a template by passing it through eval() executes the entire template as code, data and all. Template engines avoid this by parsing templates into abstract syntax trees at compile time, then efficiently replacing placeholders with data at runtime without any code execution.
For applications where users need to customize templates (email templates, report formats), treat templates as executable template logic rather than ordinary data. Provide a constrained template language with a small helper allowlist, keep prototype/property access restrictions enabled, avoid helpers that can reach files, network, or process state, and set execution limits. Never concatenate strings to build templates dynamically from user input; use the template engine's parameter binding and keep template source under trusted control where possible.
React/Vue Safe Patterns
// SafeCalculator.jsx
// SECURE - React calculator without eval
import React, { useState } from 'react';
import { create, all } from 'mathjs';
// Same hardening as the server-side example above - the raw `evaluate` export
// leaves the parser entry points that can re-enter evaluation or extend the
// language reachable from the expression itself.
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 SafeCalculator() {
const [expression, setExpression] = useState('');
const [result, setResult] = useState(null);
const calculate = () => {
if (expression.length > 200) {
setResult('Expression too long');
return;
}
try {
// Uses the hardened evaluator, not the raw mathjs export
const res = limitedEvaluate(expression);
// math.format, not the value itself: evaluate returns a Matrix for
// "[1,2]" and a Unit for "5 kg", and React throws "Objects are not
// valid as a React child" when either reaches {result}.
setResult(math.format(res, { precision: 14 }));
} catch (e) {
setResult('Invalid expression');
}
};
return (
<div>
<input
type="text"
value={expression}
onChange={(e) => setExpression(e.target.value)}
placeholder="Enter math expression"
/>
<button onClick={calculate}>Calculate</button>
<p>Result: {result}</p>
</div>
);
}
export default SafeCalculator;
The Vue equivalent takes the other route open to a calculator: rather than hardening a parser, it matches one anchored expression shape and dispatches to a fixed table of operations, so nothing the user types ever names a function.
// SafeCalculator.vue - the <script> section
// SECURE - Vue calculator without eval
export default {
data() {
return {
expression: '',
result: null,
operations: {
add: (a, b) => Number(a) + Number(b),
subtract: (a, b) => Number(a) - Number(b),
multiply: (a, b) => Number(a) * Number(b),
divide: (a, b) => {
const divisor = Number(b);
if (divisor === 0) {
throw new Error('Division by zero');
}
return Number(a) / divisor;
}
}
};
},
methods: {
calculate() {
// Use predefined operations, not eval. Anchored: an unanchored regex
// would find "1+1" inside any longer string and report success on it.
const match = this.expression.match(/^\s*(\d+)\s*([+\-*/])\s*(\d+)\s*$/);
if (!match) {
this.result = 'Invalid format';
return;
}
const [, a, op, b] = match;
const opMap = { '+': 'add', '-': 'subtract', '*': 'multiply', '/': 'divide' };
const operation = this.operations[opMap[op]];
if (!operation) {
this.result = 'Invalid operation';
return;
}
try {
this.result = operation(a, b);
} catch (e) {
this.result = e.message;
}
}
}
};
Why this works
React and Vue escape interpolated data by default, so used as intended neither turns a value into code. React's JSX syntax ensures that any data interpolated in curly braces is treated as values, not code - even if a user provides "<script>alert(1)</script>" as input, React converts it to a text string rather than executing it. Vue has similar protections with its template syntax. The key is to never use eval(), Function(), or innerHTML with untrusted data, and instead rely on the framework's built-in data binding.
For mathematical expressions in React/Vue applications, prefer predefined operations or a formula parser with a restricted grammar. These libraries run in the browser's JavaScript engine and should still be treated as parsers for untrusted input, not as a perfect sandbox. Input validation limits expression shape before processing, and CSP provides an additional browser-level defense against eval attempts.
When building calculators, form validators, or other interactive features, eval() is the convenient route and the wrong one. The predefined-operations approach takes more code up front, and it is the one where what the user types never becomes code. For scenarios needing variable support or custom functions, math.js offers those through its parser. Client-side controls are still defense-in-depth - server-side validation remains essential, because the client can be manipulated - but keeping eval() out of the browser is what stops an expression arriving from a shared link or another user's comment from executing with the victim's session.
Common Pitfalls
new Function()assumed to be meaningfully safer thaneval():Function-constructed code runs in the global scope rather than the caller's local scope, which is sometimes read as a security boundary - it isn't. The constructed function still has full access toglobalThisand everything reachable from it (Node'sprocessorrequireif exposed globally, the DOM in a browser), so it carries essentially the same arbitrary-code-execution risk aseval().- A CSP that allows
'unsafe-eval'for an unrelated reason: If any other script on the page requires'unsafe-eval'inscript-src(an older charting library, a bundler's dev-mode output, certain WASM setups), the directive applies to the whole page - it provides no protection against eval-based injection anywhere on that page, even in code that never itself needed the exemption. - A character-allowlist regex used as the sole defense in front of
eval(): A pre-filter like/^[0-9+\-*/().\s]+$/beforeeval(expression)narrows what reaches the interpreter, but it's still fragile as a standalone fix - if letters are permitted for variable names, an attacker-controlled identifier can still assemble a property or function reference the regex author didn't anticipate. Replaceeval()with a real parser rather than trying to filter input clean enough for it.