Skip to content

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:

attack-expression.js
// DANGEROUS: This pollutes ALL objects in the application!
const expression = '{}.__proto__.polluted = true';
evaluate(expression, {});
// Now EVERY object has a polluted property
const 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.

prototype-chain-attack.js
// Multiple attack vectors for prototype pollution
const 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:

multiple-attack-paths.js
// Path 1: Direct __proto__
obj.__proto__
// Path 2: Constructor prototype
obj.constructor.prototype
// Path 3: Constructor constructor (Function)
obj.constructor.constructor
// Path 4: Chain traversal
obj.__proto__.__proto__
// Path 5: Bracket notation
obj["__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

blocked-properties.js
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.

safe-context.js
// UNSAFE: Regular object has Object.prototype
const unsafe = {};
unsafe.__proto__.polluted = 'yes'; // Pollutes all objects!
// SAFE: Null prototype object - no prototype chain to exploit
const safe = Object.create(null);
safe.__proto__ = 'harmless'; // Just sets a property, no pollution

When 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:

sanitize-context.js
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:

resource-attacks.js
// 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:

security-config.js
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).

property-allowlist.js
// 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:

security-tests.js
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:

bonsai-js-example.js
import { evaluate } from 'bonsai-js';
// bonsai-js blocks prototype attacks automatically
const 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 features
function 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 usage
const rule = 'order.total > 100 && customer.tier == "gold"';
const context = {
order: { total: 150 },
customer: { tier: 'gold' }
};
const result = safeRuleEvaluation(rule, context); // true

The 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:

  1. Block dangerous properties at every access level
  2. Create all context objects with null prototypes using Object.create(null)
  3. Enforce resource limits (depth, length, timeout)
  4. 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:

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

Comments