Rendering Sanitized HTML with x-html-safe
Available since Hyvä 1.5.3
Standard Alpine.js ships an x-html directive that sets el.innerHTML directly from an expression, with no sanitization involved. The developer is responsible for making sure that content is safe. Starting with Alpine CSP 3.15, the Alpine CSP build removes x-html entirely, because rendering arbitrary HTML is exactly the kind of unrestricted capability a strict Content Security Policy is meant to prevent.
That's a real gap for Hyvä themes that lean on x-html, most commonly for price and tax markup fragments and other server-rendered HTML snippets. x-html-safe closes it. It's an Alpine plugin, built into Hyvä Theme, that renders HTML through a fixed tag and attribute allowlist instead of setting innerHTML directly, so you can still inject content without reintroducing the risk that removing x-html was meant to avoid.
Ships with hyva-themes/magento2-theme-module
x-html-safe loads automatically alongside Alpine, the same way the intersect and htmldialog plugins do. There's nothing to install. It works on both the standard and CSP Alpine builds.
You don't have to wait for a full template migration to benefit
Hyvä Theme also ships a Map x-html-safe to x-html setting (see Mapping x-html to x-html-safe below) that makes existing, un-migrated x-html usage render through the sanitized path instead. You can turn this on today, before migrating any templates yourself, to make your site more secure against unsanitized x-html output. It defaults to Disabled so upgrading never changes anything on its own, but enabling it is a one-setting way to raise your site's baseline right away.
Usage
Use x-html-safe in your templates exactly where you would have used x-html:
<div x-data="{ description: '<strong>Bold</strong> product details' }">
<div x-html-safe="description"></div>
</div>
The expression is evaluated the same way x-html's would be: whatever it returns gets sanitized and rendered as HTML. Reactive updates re-sanitize automatically, and identical input strings are served from an internal cache rather than re-parsed on every update.
How Sanitization Works
x-html-safe doesn't try to blocklist dangerous patterns. Instead, it rebuilds the output from scratch using an allowlist:
- The input string is parsed as inert HTML via
DOMParser. Nothing in it executes: noonerrororonloadhandlers fire, even for elements like<img>or<svg>. - The parsed tree is walked recursively. Each node is one of:
- Text: copied as-is.
- An allowed tag: a new element is created, allowed attributes are copied (with validation where relevant), and its children are recursed into.
- A disallowed tag: the tag itself is dropped, but its children are still recursed into and attached to the surrounding parent. Text content survives; the wrapping tag doesn't.
- Alpine directives (
x-*,:*,@*) are never in the attribute allowlist, so they can't survive sanitization. Injected content can't introduce new Alpine behavior, even if the plugin is loaded on the page.
This means an unrecognized tag doesn't need a corresponding blocklist entry. "Not on the allowlist" is the entire contract. A <script> or <iframe> gets stripped the same way an unknown or future tag would, with no separate list of "known bad" tags to maintain.
Allowed Tags
a, b, i, em, strong, s, u, span, small, mark, abbr, cite, q, sup, sub, br,
code, kbd, samp, var, wbr,
p, div, h1, h2, h3, h4, h5, h6,
ul, ol, li, dl, dt, dd,
blockquote, pre, hr,
table, thead, tbody, tfoot, tr, th, td, caption, colgroup, col
Tags such as img, picture, time, and data are intentionally left out of this release. They need dedicated validation (resource loading, date and time formats) that hasn't been built yet.
id, name, and style are never allowed on any tag. id and name enable DOM clobbering, and style is a layout-attack vector.
class is allowed and copied through unvalidated, same as data-*. On sites using a CSS framework with a build-time class scanner (Tailwind's arbitrary-value syntax, class="bg-[url(...)]", is the concrete example), an attacker-controlled class value can act as a resource-loading vector — but only when that scanner is actually active, and in that case the same risk exists anywhere else user content reaches a class attribute on the page, independent of this plugin. x-html-safe doesn't special-case any particular framework's scanner behavior.
Allowed Attributes
These attributes are available on every allowed tag:
| Attribute | Notes |
|---|---|
title |
|
aria-label |
|
hidden |
Use in place of aria-hidden. It hides content from display and the accessibility tree in one attribute, with no value to inject. |
role |
|
lang, dir |
|
class |
Copied unvalidated. See the note above about CSS-framework class scanners. |
data-* |
Copied via the browser's dataset API. The sanitizer copies the values; validating them is the consuming component's responsibility. |
These attributes are available only on specific tags, each with its own validation:
| Tag | Attribute | Validation |
|---|---|---|
a |
href |
Must resolve to https:, http:, mailto:, or tel:. javascript: and data: URLs are rejected. |
a |
target |
Restricted to _blank, _self, _parent, _top |
a |
rel |
Passed through as authored |
blockquote, q |
cite |
Same URL validation as href |
li |
value |
Any valid integer, no spec-defined bound |
ol |
start |
Any valid integer, including negative |
ol |
reversed |
Boolean presence attribute |
td, th |
colspan |
1-1000 (WHATWG cap) |
td, th |
rowspan |
1-65534 (WHATWG cap, deliberately distinct from colspan's) |
col |
span |
1-1000 |
Cross-origin links get rel="noopener noreferrer" automatically
Any <a> whose href resolves to a different origin than the current page gets noopener noreferrer added to its rel attribute, regardless of what target is set to (or left unset).
This closes reverse-tabnabbing. A user can open any link in a new browsing context through ctrl/cmd-click, middle-click, or a context-menu "open in new tab", independent of the link's own target value, so the guard can't be conditioned on target="_blank". Same-origin links are left untouched, since there's no trust boundary to protect from a page linking to itself.
What's Not Handled
- No developer configuration for the allowlist itself. The tag and attribute allowlist is fixed. A misconfigured
allowTags: ['script']-style escape hatch would defeat the sanitizer's entire premise, so there isn't one. If a real use case needs a tag or attribute that isn't covered, that's a change to the plugin, not a runtime option. - No Alpine directives in injected content.
x-*,:*, and@*attributes are stripped unconditionally. There's no "safe subset" of Alpine directives allowed through in this release.
Mapping x-html to x-html-safe
By default, x-html-safe registers only itself. It doesn't touch x-html, so the same attribute keeps meaning exactly what the upstream Alpine docs say it means, everywhere, regardless of which build happens to be loaded. That matters because Alpine CSP has no x-html directive at all: any template not yet migrated to x-html-safe renders nothing there, with no error to point at why.
To bridge that gap during migration, Hyvä Theme ships a setting that maps x-html onto the same sanitized rendering x-html-safe uses:
- Go to Stores -> Configuration
- Then Hyvä Themes -> General -> HTML Safe
- Map x-html-safe to x-html
- Disabled (default):
x-html-safeis never mapped tox-html. Existingx-htmlbehavior is left exactly as-is on both builds, so upgrading the theme module doesn't change anything on its own. Un-migratedx-htmlkeeps rendering nothing on CSP pages until templates are updated to usex-html-safedirectly, or this setting is turned on. - On CSP pages only: un-migrated
x-htmlusage renders through the sanitized path on the CSP build, wherex-htmlwould otherwise render nothing. The regular build is untouched, sincex-htmlalready works there. - On all pages: also maps
x-htmlto the sanitized behavior on the regular build. Existingx-htmlusage there switches from raw, unsanitizedinnerHTMLto allowlist-filtered rendering.
- Disabled (default):
- Map x-html-safe to x-html
This setting doesn't decide anything for Alpine itself, since a plugin doesn't get to unilaterally redefine what a directive Alpine ships means everywhere. It only affects Hyvä's own installation: the theme reads the setting and decides, at that layer, whether to also register x-html-safe's handler under the name html.
Defaults to disabled on purpose
This setting defaults to Disabled rather than On CSP pages only, so upgrading the theme module never silently changes what an un-migrated x-html template renders. Turn the mapping on deliberately once you've reviewed what your un-migrated x-html templates actually render.
One handler behind both names shares one cache
Both x-html-safe and the x-html bridge (when enabled) call into the exact same sanitizer function, including its cache. Sanitizing the same HTML string once, whether through x-html-safe or the aliased x-html, serves the cached result to the other the next time that string comes up. There's no separate cache to keep in sync.
Migrating Existing Templates
Hyvä's own default CSP theme is migrating its x-html usage to x-html-safe incrementally, template by template, rather than all at once. The confirmed-safe order of operations for any template:
- Check what the bound expression actually returns. Plain text or markup that only uses allowed tags/attributes (styling classes included) can be renamed directly, with no visible change.
- If the markup relies on a genuinely blocked attribute (
id,name,style), decide whether that usage can be dropped or needs a different approach — these stay blocked because they enable real attacks (DOM clobbering, layout injection), not because they're inconvenient. - If the content is dynamic or comes from a third party (AJAX responses, customer-data sections, other extensions), verify what it actually renders before migrating. A cart drawer's "extra actions" section, for example, can carry arbitrary HTML from any installed module, exactly the kind of untrusted content
x-html-safeexists to handle safely, but it's worth confirming nothing currently depends on tags or attributes outside the allowlist before switching it over.
Un-migrated templates keep behaving exactly as they do today until they're migrated: still rendering nothing on CSP pages, since the bridge defaults to Disabled. Turn on On CSP pages only if you'd rather have them render sanitized content in the meantime while migration is in progress.
Related Topics
- Alpine CSP: x-html-safe: Why the CSP build needs this plugin, in short
- Alpine CSP: Overview of Alpine CSP build limitations and how to write compatible code
- Alpine CSP Example Component: Complete working example of a CSP-compatible component