Preventing prototype pollution is essential for Node.js security. Learn how to stop recursive object injection and harden your code against common attacks.
During a recent security audit of a legacy Express microservice, I traced a mysterious crash back to a simple utility function that merged user-provided configuration objects. The app would occasionally restart with a RangeError: Maximum call stack size exceeded error, seemingly at random. It turned out that an attacker was sending a specifically crafted JSON payload that modified the global Object.prototype, effectively poisoning every object in the application.
If you’re working in Node.js, you’ve likely used Object.assign or a recursive merge utility at some point. If those utilities don't account for the __proto__ or constructor properties, your application is vulnerable to prototype pollution.
At its core, prototype pollution occurs when an application recursively merges or assigns properties to an object without sanitizing the keys. In JavaScript, all objects inherit properties from Object.prototype. If an attacker can inject a property into this base prototype, they can change the behavior of every object in your runtime.
Consider this vulnerable recursive merge function:
JAVASCRIPTfunction merge(target, source) { for (let key in source) { if (typeof source[key] === CE9178">'object') { merge(target[key], source[key]); } else { target[key] = source[key]; } } }
If you pass a payload like {"__proto__": {"isAdmin": true}} to this function, the merge logic will traverse the prototype chain. It ends up setting isAdmin on Object.prototype. Suddenly, every object in your system—even those that shouldn't have access—now evaluates myObject.isAdmin as true. This is a classic example of javascript object injection that leads to logic bypasses and, in some cases, remote code execution.
When we first encountered this, we tried patching the merge function to ignore __proto__. It worked for that specific case, but we missed other vectors like constructor.prototype. We eventually realized that vulnerability mitigation requires a defense-in-depth approach rather than just blacklisting specific keys.
Here are the steps I now follow to keep my services secure:
Object.create(null): If you need a dictionary object that doesn't inherit from the base prototype, initialize it with Object.create(null). These objects don't have a __proto__ property, making them immune to this class of attack.Object.freeze(Object.prototype) at the start of your application. While this can break some third-party libraries that rely on prototype modification, it’s an effective nuclear option for high-security environments.JAVASCRIPTfunction safeMerge(target, source) { const dangerousKeys = [CE9178">'__proto__', CE9178">'constructor', CE9178">'prototype']; for (let key of Object.keys(source)) { if (dangerousKeys.includes(key)) continue; if (typeof source[key] === CE9178">'object' && source[key] !== null) { safeMerge(target[key] || {}, source[key]); } else { target[key] = source[key]; } } }
Beyond simple logic bypasses, prototype pollution can be chained with other vulnerabilities. For instance, if your application uses a template engine that relies on object properties, an attacker might inject malicious template variables.
We often focus on SQL injection or SSRF prevention, but prototype pollution is insidious because it doesn't leave an obvious footprint in your logs. It quietly alters the runtime state of your application. If you’re handling data from external sources, you should also be mindful of insecure deserialization, as these two often go hand-in-hand in modern Node.js applications.
Is this only a problem in Node.js? While this is a common topic in node.js security, it’s a fundamental quirk of the JavaScript language. Browser-based JavaScript is just as susceptible to prototype pollution if the application logic recursively merges untrusted data.
Should I use Object.freeze in production?
It depends. It’s a great security measure, but test it thoroughly. Many older libraries and some Node.js internals rely on prototype modification. If you use it, do it early in your entry point file.
What is the best way to handle configuration? Avoid merging raw JSON directly into your application state. Instead, map the input to a predefined class or a DTO (Data Transfer Object) that explicitly defines the allowed properties.
I’m still not entirely convinced that blacklisting keys is a permanent solution. As the JavaScript ecosystem evolves, new ways to traverse the prototype chain might appear. For now, I’m moving toward strict schema validation for all incoming network requests. It adds about 5-10ms of overhead per request, but the peace of mind is worth it. Don't wait for a production crash to audit your merge utilities.
Handling secrets securely is non-negotiable for production apps. Learn how to stop leaking API keys and database credentials in your codebase today.