Skip to content

How to Safely Evaluate User-Defined Expressions in JavaScript Without eval

I needed to let users define custom formulas in my application. My first instinct was to use JavaScript’s eval() function. That decision almost cost me my production server.

dangerous.js
// What I originally wrote
const userFormula = "price * quantity * discount";
const result = eval(userFormula); // Works... but at what cost?

It worked for simple math. Then a user submitted this expression:

attack.js
"require('child_process').exec('rm -rf /')"

My server would have executed arbitrary system commands. This is the fundamental problem with eval() - it runs ANY JavaScript code with full access to your runtime environment.

Environment

  • Node.js 18+
  • Expression evaluation for business rules
  • User-submitted formulas for pricing, filtering, and calculations
  • No external sandboxing infrastructure available

What Happened

When I deployed the eval-based formula system, I noticed three critical vulnerabilities during security testing:

  1. Arbitrary code execution - Users could call require(), process.exit(), and access the file system
  2. Prototype pollution - Expressions like {}.__proto__.polluted = true modified all objects
  3. Denial of service - Infinite loops and deep recursion crashed the process

I tried using new Function() instead of eval(), hoping it would be safer. It wasn’t.

still-dangerous.js
// This is NOT safer
const fn = new Function("price", "quantity", "return price * quantity");
fn(100, 5); // 500
// Attackers can still escape:
const attack = new Function("return this.constructor.constructor('return process')()");
// Returns the process object - full system access

new Function() creates functions in the global scope. An attacker can access this.constructor.constructor to get the Function constructor, then create arbitrary code.

Solution

I switched to bonsai-js, a dedicated expression evaluation library that:

  • Blocks dangerous property access (__proto__, constructor, prototype)
  • Enforces resource limits (max depth, max operations)
  • Has zero dependencies and 30M ops/sec performance
safe-evaluation.js
import { evaluate, compile } from 'bonsai-js';
// One-off evaluation
const result = evaluate('price * quantity * discount', {
price: 100,
quantity: 5,
discount: 0.9
});
// Result: 450
// Compile for repeated use (better performance)
const formula = compile('a + b * c');
formula({ a: 1, b: 2, c: 3 }); // 7
formula({ a: 10, b: 5, c: 2 }); // 20
// Attacks are blocked automatically
evaluate('obj.__proto__.polluted = true', { obj: {} }); // Error: Blocked!
evaluate('constructor.constructor("return process")()', {}); // Error: Blocked!

Custom Functions

I needed to support custom functions for string formatting and date calculations:

custom-functions.js
import { evaluate } from 'bonsai-js';
const result = evaluate('formatCurrency(price * quantity)', {
price: 99.99,
quantity: 3,
formatCurrency: (n) => `$${n.toFixed(2)}`
});
// Result: '$299.97'

The context object only contains what I explicitly provide. Users cannot access globals, modules, or anything outside the context.

Security Layers

Even with a safe library, I added defense-in-depth measures:

security-layers.js
const BLOCKED_PROPERTIES = new Set([
'__proto__',
'constructor',
'prototype',
'__defineGetter__',
'__defineSetter__',
]);
// Sanitize context objects with null prototype
function createSafeContext(data) {
const safe = Object.create(null); // No prototype chain
for (const [key, value] of Object.entries(data)) {
if (BLOCKED_PROPERTIES.has(key)) continue;
if (typeof value === 'object' && value !== null) {
safe[key] = createSafeContext(value);
} else {
safe[key] = value;
}
}
return Object.freeze(safe);
}
// Usage
const context = createSafeContext({
user: { name: 'Alice', balance: 100 }
});
// context.__proto__ is undefined
// context.constructor is undefined

Objects created with Object.create(null) have no prototype, so prototype chain attacks fail.

Resource Limits

To prevent DoS attacks, I enforce timeouts:

timeout.js
import { evaluate } from 'bonsai-js';
function safeEvaluateWithTimeout(expression, context, timeoutMs = 100) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('Expression evaluation timeout'));
}, timeoutMs);
try {
const result = evaluate(expression, context);
clearTimeout(timer);
resolve(result);
} catch (error) {
clearTimeout(timer);
reject(error);
}
});
}
// Usage
await safeEvaluateWithTimeout('price * quantity', { price: 100, quantity: 5 });

For even stronger isolation, I run evaluations in a Web Worker with a hard timeout:

worker.js
// worker.js
import { evaluate } from 'bonsai-js';
self.onmessage = function(e) {
const { expression, context } = e.data;
try {
const result = evaluate(expression, context);
self.postMessage({ success: true, result });
} catch (error) {
self.postMessage({ success: false, error: error.message });
}
};
// main.js
function sandboxedEvaluate(expression, context, timeoutMs = 1000) {
return new Promise((resolve, reject) => {
const worker = new Worker('worker.js');
const timeout = setTimeout(() => {
worker.terminate();
reject(new Error('Timeout'));
}, timeoutMs);
worker.onmessage = (e) => {
clearTimeout(timeout);
worker.terminate();
if (e.data.success) {
resolve(e.data.result);
} else {
reject(new Error(e.data.error));
}
};
worker.postMessage({ expression, context });
});
}

The Web Worker runs in a separate thread. If it hangs, terminating the worker doesn’t affect the main process.

Why This Works

The key insight is that safe expression evaluation requires a whitelist approach, not a blacklist:

  1. Define allowed operations - Only arithmetic, comparisons, and property access
  2. Block dangerous paths - No access to constructors, prototypes, or global objects
  3. Limit resources - Prevent infinite loops with timeouts and depth limits
  4. Isolate execution - Web Workers provide process-level isolation

eval() and new Function() are blacklist approaches - they allow everything by default. Even if you block known attack vectors, new ones emerge. A dedicated parser like bonsai-js only allows what you explicitly permit.

The performance is excellent too. bonsai-js achieves 30 million operations per second because it parses expressions once and compiles them to optimized functions. For repeated evaluations, compile() is significantly faster than evaluate().

Summary

In this post, I showed how I replaced eval() with a safe expression evaluation system. The key points are:

  • Never use eval() or new Function() for user input - they allow arbitrary code execution
  • Use a dedicated expression library like bonsai-js that blocks dangerous property access
  • Add defense-in-depth with null-prototype objects, resource limits, and Web Worker isolation
  • Compile expressions for repeated use to get better performance

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments