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.
// What I originally wroteconst userFormula = "price * quantity * discount";const result = eval(userFormula); // Works... but at what cost?It worked for simple math. Then a user submitted this expression:
"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:
- Arbitrary code execution - Users could call
require(),process.exit(), and access the file system - Prototype pollution - Expressions like
{}.__proto__.polluted = truemodified all objects - 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.
// This is NOT saferconst 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 accessnew 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
import { evaluate, compile } from 'bonsai-js';
// One-off evaluationconst 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 }); // 7formula({ a: 10, b: 5, c: 2 }); // 20
// Attacks are blocked automaticallyevaluate('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:
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:
const BLOCKED_PROPERTIES = new Set([ '__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__',]);
// Sanitize context objects with null prototypefunction 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);}
// Usageconst context = createSafeContext({ user: { name: 'Alice', balance: 100 }});// context.__proto__ is undefined// context.constructor is undefinedObjects created with Object.create(null) have no prototype, so prototype chain attacks fail.
Resource Limits
To prevent DoS attacks, I enforce timeouts:
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); } });}
// Usageawait safeEvaluateWithTimeout('price * quantity', { price: 100, quantity: 5 });For even stronger isolation, I run evaluations in a Web Worker with a hard timeout:
// worker.jsimport { 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.jsfunction 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:
- Define allowed operations - Only arithmetic, comparisons, and property access
- Block dangerous paths - No access to constructors, prototypes, or global objects
- Limit resources - Prevent infinite loops with timeouts and depth limits
- 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()ornew 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:
- 👨💻 bonsai-js GitHub
- 👨💻 Jexl Documentation
- 👨💻 OWASP Code Injection Prevention
- 👨💻 MDN eval() Documentation
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments