How to Secure Sandboxed JavaScript Expression Evaluation Against Prototype Pollution
Problem
When I built a user-facing rule builder for my application, I needed to evaluate JavaScript expressions like order.total > 100 && customer.tier == "gold". I thought using a sandboxed expression evaluator would be safe. Then I discovered this attack vector:
// DANGEROUS: This pollutes ALL objects in the application!const expression = '{}.__proto__.polluted = true';evaluate(expression, {});
// Now EVERY object has a polluted propertyconst innocent = {};console.log(innocent.polluted); // true - prototype is compromised!I had introduced a critical security vulnerability. Any user who could input expressions could inject properties into every object in my application.
What Happened
The expression evaluator I was using allowed property access to __proto__. This is the prototype pollution attack pattern. In JavaScript, every object inherits from a prototype chain. When I modify Object.prototype, I modify the inheritance of every object in the runtime.
// Multiple attack vectors for prototype pollutionconst attacks = [ // Direct __proto__ access '{}.__proto__.admin = true',
// Constructor chain traversal 'x.constructor.prototype.admin = true',
// Bracket notation 'user["__proto__"]["isAdmin"] = true',
// Nested access 'obj.__proto__.__proto__.polluted = true'];Each of these expressions, if executed by an unprotected evaluator, would compromise the entire application.
Why Blocking proto Alone Is Not Enough
I initially thought blocking __proto__ would solve the problem. I was wrong. Attackers have multiple paths to the prototype chain:
// Path 1: Direct __proto__obj.__proto__
// Path 2: Constructor prototypeobj.constructor.prototype
// Path 3: Constructor constructor (Function)obj.constructor.constructor
// Path 4: Chain traversalobj.__proto__.__proto__
// Path 5: Bracket notationobj["__proto__"]obj["constructor"]["prototype"]I need to block ALL of these access paths, not just the obvious one.
Solution
I implemented a multi-layered defense strategy. Hereβs what I learned.
Layer 1: Block Dangerous Properties at Every Access Level
const BLOCKED_PROPERTIES = new Set([ '__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__',]);
function safePropertyAccess(prop) { if (BLOCKED_PROPERTIES.has(prop)) { throw new SecurityError(`Access to '${prop}' is blocked`); } return prop;}The key insight: I must check property access at evaluation time, not just at parse time. Attackers can use dynamic property access like obj[key] where key comes from context data.
Layer 2: Create Safe Context Objects with Null Prototypes
The most effective defense: eliminate the prototype chain entirely.
// UNSAFE: Regular object has Object.prototypeconst unsafe = {};unsafe.__proto__.polluted = 'yes'; // Pollutes all objects!
// SAFE: Null prototype object - no prototype chain to exploitconst safe = Object.create(null);safe.__proto__ = 'harmless'; // Just sets a property, no pollutionWhen I create objects with Object.create(null), they have no prototype. Property assignment is just property assignment - no prototype pollution possible.
For context objects passed to the evaluator, I sanitize deeply:
function createSafeContext(data, depth = 0) { // Prevent infinite recursion if (depth > 10) return undefined; if (data === null || data === undefined) return data; if (typeof data !== 'object') return data;
// Create null-prototype object - eliminates prototype chain const safe = Object.create(null);
// Handle arrays if (Array.isArray(data)) { const arr = []; for (let i = 0; i < Math.min(data.length, 10000); i++) { arr[i] = createSafeContext(data[i], depth + 1); } return Object.freeze(arr); }
// Handle objects for (const [key, value] of Object.entries(data)) { // Skip blocked properties if (BLOCKED_PROPERTIES.has(key)) continue; safe[key] = createSafeContext(value, depth + 1); }
// Freeze to prevent modification return Object.freeze(safe);}Layer 3: Enforce Resource Limits
Without limits, attackers can exhaust resources:
// Attack vectors:// 1. Deeply nested expressions'((((((((((((((((((((((((1))))))))))))))))))))))))'
// 2. Large array creation'[1,2,3,4,5,6,7,8,9,10,...(millions of elements)]'
// 3. Exponential complexity'2**2**2**2**2**2**2**2**2**2'I enforce these limits:
const SECURITY_CONFIG = { blockedProperties: new Set([ '__proto__', 'constructor', 'prototype', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', ]),
limits: { maxDepth: 20, // Maximum nesting depth maxArrayLength: 10000, // Maximum array size maxStringLength: 100000,// Maximum string length maxOperations: 100000, // Maximum AST operations timeoutMs: 100, // Milliseconds before timeout },};Layer 4: Use Property Allowlists
Allowlists are more secure than denylists. A denylist fails open (if I forget a dangerous property, itβs exploitable). An allowlist fails closed (if I forget a safe property, itβs just unavailable).
// Allowlist approach (recommended for production)const ALLOWED_PROPERTIES = new Set([ 'name', 'id', 'price', 'quantity', 'total', 'status', 'createdAt', 'updatedAt', 'order', 'customer']);
function evaluatePropertyAccess(obj, prop) { if (!ALLOWED_PROPERTIES.has(prop)) { throw new SecurityError(`Property '${prop}' is not allowed`); } return obj[prop];}Testing Security
I maintain a test suite of known attack patterns:
const ATTACK_PATTERNS = [ // Prototype pollution '{}.__proto__.polluted = 1', 'x["__proto__"]["polluted"] = 1', 'x.constructor.prototype.polluted = 1',
// Constructor exploitation 'constructor.constructor("return this")()', 'this.constructor.constructor("return process")()',
// Global access attempts 'this.process', 'global.process', 'window.location',
// Resource exhaustion 'a'.repeat(1000000), '[' + '1,'.repeat(100000) + '1]', '2**2**2**2**2**2**2**2**2**2',];
function runSecurityTests(evaluator) { const results = [];
for (const attack of ATTACK_PATTERNS) { try { evaluator(attack, {}); results.push({ attack, status: 'FAIL', reason: 'No error thrown' }); } catch (error) { results.push({ attack, status: 'PASS', reason: error.message }); } }
return results;}Using bonsai-js
After implementing these defenses manually, I discovered bonsai-js handles all of this automatically. The library implements the security measures I described:
import { evaluate } from 'bonsai-js';
// bonsai-js blocks prototype attacks automaticallyconst attacks = [ 'obj.__proto__.polluted = true', 'constructor.constructor("return process")()', 'x["constructor"]["prototype"]',];
attacks.forEach(attack => { try { evaluate(attack, {}); console.log('FAIL:', attack); } catch (e) { console.log('PASS:', attack, '-', e.message); }});
// Safe rule evaluation for user-facing featuresfunction safeRuleEvaluation(rule, context) { const safeContext = createSafeContext(context, { allowedProperties: new Set([ 'order', 'customer', 'product', 'total', 'tier', 'status' ]) });
return evaluate(rule, safeContext, { maxDepth: 15, timeout: 50, });}
// Example usageconst rule = 'order.total > 100 && customer.tier == "gold"';const context = { order: { total: 150 }, customer: { tier: 'gold' }};const result = safeRuleEvaluation(rule, context); // trueThe library claims 30M ops/sec with zero dependencies, which is impressive for a fully secured expression evaluator.
Security Audit Checklist
Before deploying any expression evaluator, I verify:
- All prototype chain access blocked (
__proto__,constructor,prototype) - Resource limits enforced (depth, length, timeout)
- Property allowlists implemented
- Context objects created with
Object.create(null) - Context objects frozen with
Object.freeze() - Error messages donβt leak sensitive information
- No access to global objects (window, global, process)
- No access to dangerous functions (eval, Function, require)
- Comprehensive test suite with attack patterns
- Regular security audits and dependency updates
Summary
In this post, I showed how to secure sandboxed JavaScript expression evaluation against prototype pollution attacks. The key point is that blocking __proto__ alone is insufficient - attackers can access the prototype chain through constructor.prototype and other paths.
The defense strategy has four layers:
- Block dangerous properties at every access level
- Create all context objects with null prototypes using
Object.create(null) - Enforce resource limits (depth, length, timeout)
- Use allowlists instead of denylists for property access
For production use, libraries like bonsai-js implement these protections out of the box, saving me from implementing and maintaining my own security layer.
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 Repository
- π¨βπ» OWASP Prototype Pollution Prevention
- π¨βπ» MDN Object.create() Documentation
- π¨βπ» Node.js Security Best Practices
Oh, and if you found these resources useful, donβt forget to support me by starring the repo on GitHub!
Comments