New alternatives to innerHTML
Browser support note: setHTMLUnsafe
is supported in all browsers. setHTML
is still being standardised and is only available in Firefox behind a flag. getHTML
is supported in Chrome and Edge since version 125.
Browsers recently implemented a new setHTMLUnsafe
method. Unsafe in this context means that, just like innerHTML
, it does not perform input sanitization. This naming is not consistent with previous browser APIs: we have innerHTML
, not innerHTMLUnsafe
; eval()
not evalUnsafe()
, etc. setHTMLUnsafe
is certainly no more dangerous than these older methods. Unlike the older methods though, there is both a safe version (setHTML
) and an unsafe version (setHTMLUnsafe
) — hence the naming.
Here’s what the Sanitizer API spec has to say:
The “safe” methods will not generate any markup that executes script. That is, they should be safe from XSS.
Let’s imagine we have a HTML form with a text <input>
and some JavaScript code that changes the DOM based on the user-supplied value:
form.addEventListener("submit", function (event) {
event.preventDefault()
const markup = `<h2>${input.value}</h2>`
div.innerHTML = markup
})
If a user entered <img src=doesnotexist onerror="alert('Potential XSS Attack')">
into the input, that JavaScript code would run in the browser. .setHTMLUnsafe()
has the same problem.
In this simplistic example the code is only running in the users own browser, but if this sort of user input is stored in a database and used to display dynamic content to others, arbitrary and potentially malicious JavaScript could run in the browsers of other users.
Using setHTML
, the only thing inserted into the DOM is <img src="doesnotexist">
. The image is still injected into the page, but the JavaScript is stripped out.
The Sanitizer API is still a work in progress, but it helps put the naming of setHTMLUnsafe
in context.
setHTMLUnsafe
If we’re (hopefully) getting setHTML
, and we already have innerHTML
, why do we even need setHTMLUnsafe
? The answer is declarative shadow DOM.
The HTML <template>
element can be used in two different ways:
- To hold a HTML fragment which is not rendered but that can be used later via JavaScript.
- To immediately generate a shadow DOM. If the
<template>
contains theshadowrootmode
attribute, the element is replaced in the DOM by its content, inside a shadow root.
innerHTML
plays nicely with the first use case, but can’t handle the second.
const main = document.querySelector("main")
main.innerHTML = `
<h2>I am in the Light DOM</h2>
<div>
<template shadowrootmode="open">
<style>
h2 { color: blue; }
</style>
<h2>Shadow DOM</h2>
</template>
</div>`
innerHTML
does inject the <template>
into the page, but it remains a <template>
element — it does not get turned into shadow DOM and its contents do not get rendered, regardless of the shadowrootmode
attribute.
setHTML
will purposefully remove the template
and its contents:
const main = document.querySelector("main")
main.setHTML(`
<h2>I am in the Light DOM</h2>
<div>
<template shadowrootmode="open">
<style>
h2 { color: blue; }
</style>
<h2>Shadow DOM</h2>
</template>
</div>`)
In the above example, the contents of the main
is now a h2
and an empty div
. The template is treated as an “unsafe node”.
This is why browsers added setHTMLUnsafe
, as a way to dynamically add declarative shadow DOM to the page.
main.setHTMLUnsafe(`
<h2>I am in the Light DOM</h2>
<div>
<template shadowrootmode="open">
<style>
h2 { color: blue; }
</style>
<h2>Shadow DOM</h2>
</template>
</div>
`)
When using setHTMLUnsafe
, the contents of the <template>
will be rendered inside of shadow DOM.
getHTML
setHTML
and setHTMLUnsafe
aren’t, by themselves, a full replacement for innerHTML
. innerHTML
can both set and get HTML. The complementary function to setHTML
and setHTMLUnsafe
is getHTML
(there is no unsafe version).
const main = document.querySelector("main")
const html = main.getHTML()
By default getHTML
won’t return any markup from within a shadow DOM, but it is configurable.
const main = document.querySelector("main")
const html = main.getHTML({ serializableShadowRoots: true })
Setting serializableShadowRoots
to true will serialize all shadow DOM trees that have opted-in to serialization.
A template
element can opt-in using the shadowrootserializable
attribute:
<template shadowrootmode="open" shadowrootserializable></template>
Similarly, in JavaScript, the attachShadow
method has a boolean serializable
option.
this.attachShadow({ mode: "open", serializable: true })
It’s also possible to serialize only certain specified shadow DOM trees by passing an array of shadow roots:
const markup = main.getHTML({
shadowRoots: [document.querySelector(".example").shadowRoot],
})
All shadow roots in the array will be serialized, even if they are not marked as serializable.