How do I control HTML sanitization?
Escaping with html
Write templates with the html tag. Every ${} in an html template is HTML-escaped, wherever the value comes from (an input, an HTTP response, localStorage, the server):
import { Component, html, raw, trustedRaw } from '../../../core/index.js';
const templateFn = (task: ITask) => html`<li title="${task.name}">${task.name}</li>`;
A task named <button data-event="click:_remove">x</button> renders as that text, not as a button.
- Nested
htmlresults are inserted as-is, so templates can be built from helper functions that returnhtml. - Arrays are inserted item by item. Map to
htmlresults and leave out.join(''):
const templateFn = (tasks: ITask[]) => html`<ul>${tasks.map((task) => html`<li>${task.name}</li>`)}</ul>`;
null,undefinedandfalserender as nothing, so${done && htmlDone}works.- An unquoted attribute value is quoted for you:
html`` renderstitle="…", so a value with spaces can't add attributes. Quote your attributes anyway. raw()inserts markup unescaped, e.g. rich text from a CMS. At render it's parsed where it sits (inside<svg>,<table>, … too), Nutin's binding attributes (data-event,data-component,data-catalog,data-bind,data-i18n,data-pipe,data-pipe-source) are removed from it, and it's sanitized at the component'strustLevel(below). So the markup can't call your component's methods or mount children:
const templateFn = (post: IPost) => html`<article>${raw(post.bodyHtml)}</article>`;
For HTML written by your users, sanitize it before raw(): see Untrusted rich HTML.
trustedRaw()inserts markup as-is, binding attributes included. Use it only for markup you wrote or compiled yourself, e.g. Markdown pages whose internal links usedata-event:
const templateFn = (page: IPage) => html`<article>${trustedRaw(page.html)}</article>`;
Where raw() sits decides how it's handled:
- Between elements: complete markup, as above. Half an element (an unclosed
<div>) gets closed by the parser. - Inside a tag (
<input ${raw('checked')}>): attributes only. Event handlers,srcdoc, binding attributes andjavascript:URLs are dropped. - Inside an attribute value: escaped like any other value.
- Inside
<textarea>or<title>, which only hold text: inserted as text, the wayinnerHTMLwould put it there. Entities are decoded and tags show as written, andraw('</textarea>…')can't close the element.
An untagged template literal escapes nothing. The build warns about every untagged template/templateFn that contains ${}.
Trust levels
Every component's rendered template is also sanitized before it's inserted. It's parsed once and the sanitized nodes are inserted as they are, never turned back into a string and parsed again (that second parse is what mutation XSS relies on). It's a second layer, mainly for raw() content. Set trustLevel once, via super():
export class NoteComponent extends Component {
constructor(mountTarget: HTMLElement, config: { html: string }) {
// trust the rich-text HTML for this component's entire template
super({ templateFn: () => html`<div>${raw(config.html)}</div>`, mountTarget, trustLevel: 'trusted' });
}
}
// Nutin.TrustLevel
type TrustLevel = 'strict' | 'normal' | 'trusted'; // default: 'normal'
| Level | Behavior |
|---|---|
trusted |
No sanitization: the markup is used as written. raw() still removes binding attributes. |
normal |
Default. Strips <script>, <style>, <link>, <base> and <meta> elements (HTML, SVG and MathML alike), any attribute whose name starts with on (onclick, onerror, ...), srcdoc attributes, SVG <animate>/<set> elements that rewrite one of the URL attributes below, and href/src/action/formaction/poster/background/xlink:href attributes whose value is a javascript: URL (including whitespace-obfuscated variants like java\tscript:). Also removes data: and javascript: documents from <iframe>/<object>/<embed> (src/data). |
strict |
Everything normal does, plus strips <iframe>, <object>, <embed> tags, and also removes data: URLs from the URL attributes. |
trustLevel is per-instance and applies to every render. It's a blocklist, so it can't catch everything. It also keeps data-event and the other binding attributes, because your own template needs them. Never rely on it alone for untrusted data: escape with html, and sanitize untrusted HTML with DOMPurify before raw().
Escaping can't help with a URL: in html`<a href="${link}">, a link of javascript:… contains nothing to escape. So html checks values in href, src, action, formaction, poster, background and xlink:href itself, at every trust level: a value that makes the attribute a javascript: URL is replaced with about:invalid#nutin-blocked, and a warning is logged in development. A URL whose scheme is written in the template (href="/users/${id}", href="https://…/${path}") is left alone. Other schemes are still up to you: data: URLs are only removed at strict.
At trusted, data inside an event handler or srcdoc attribute runs as code: in onclick="go('${x}')", the escaped quotes in x are decoded before the script runs. Use data-event with a token instead (below).
Untrusted rich HTML
When the HTML comes from users (comments, messages, a third-party feed), run it through DOMPurify first, then pass the result to raw():
npm install dompurify
import DOMPurify from 'dompurify';
import { Component, html, raw } from '../../../core/index.js';
const templateFn = (comment: IComment) => html`<div class="comment">${raw(DOMPurify.sanitize(comment.bodyHtml))}</div>`;
Each does a different job:
- DOMPurify is an allowlist, kept up to date against browser parsing quirks. Nutin's trust levels are a blocklist (above).
- DOMPurify keeps
data-*attributes by default, sodata-eventanddata-componentsurvive it.raw()is what removes them, so the markup can't call your component's methods.
Data in data-event and style
Escaping keeps data inside the attribute, but these attributes give meaning to its content:
data-eventarguments are split on,and@tokensare resolved, sodata-event="click:_select:${item.id}"with an id like1,@targetpasses extra arguments. Put the value in its own attribute and read it with a token:data-id="${item.id}" data-event="click:_select:@dataset:id".style="color:${color}"lets the value add other declarations (red;background-image:url(…)). Validate it, or pick from a fixed list of classes instead.
Escaping outside templates
SecurityHelper.escapeHtml() escapes a single value, e.g. for a string you build outside an html template:
import { SecurityHelper } from '../../../core/index.js';
const safeName = SecurityHelper.escapeHtml(userInput.name);
It returns '' for null/undefined and stringifies other values first. Don't pass its result into an html template: it would be escaped twice.
SecurityHelper also exposes the steps render() uses, for markup you insert yourself:
sanitizeToFragment(value, trustLevel?)— sanitizes a template and returns its nodes, ready to insert (whatrender()does).sanitizeTemplate(value, trustLevel?)returns the sanitized markup as a string.isScriptUrl(name, value)— whethervaluemakes the URL attributename(href,src, …) ajavascript:URL.stripBindings(markup)/stripBindingsFrom(root)— remove Nutin's binding attributes (data-event,data-component,data-bind, …) from a string, or in place from a DOM tree.
Token values are raw
data-event tokens (@value, @textContent, @attr:name, ...) and getValues() pass raw values to your handlers: typing a & b gives a & b. They're escaped when you render them through an html template.