Sanitize untrusted HTML — @zoijs/sanitize
Turn an untrusted HTML string (a markdown render, a CMS body, an email preview) into safe DOM nodes you can drop straight into a Zoijs binding. Allowlist-based, zero-dependency, and it reuses the same URL/attribute guards the core renderer uses.
npm i @zoijs/sanitize # peer: @zoijs/core ^1.0.0Zoijs has no raw-HTML API by design — a string in a text slot is inert text, and there is deliberately no unsafeHTML. But real apps still need to render rich HTML they mostly trust, and that boundary is the single most security-critical spot in a front end. sanitize() makes it a supported, tested path instead of one you solve alone.
Use it in a binding#
sanitize(dirtyHtml) returns an array of safe DOM nodes. A Zoijs text binding renders returned nodes as-is and tracks them for clean removal on update:
import { html, mount } from "@zoijs/core";
import { sanitize } from "@zoijs/sanitize";
function Post({ bodyHtml }) {
return html`<article>${() => sanitize(bodyHtml)}</article>`;
}
mount(() => Post({ bodyHtml: renderedMarkdown }), "#app");Everything dangerous is removed; the safe rich text is kept:
sanitize('<p onclick="steal()">Hi <strong>there</strong> <script>evil()</script></p>');
// → nodes for: <p>Hi <strong>there</strong></p>Why it's safe#
- Inert parse. The string is parsed with
DOMParserinto a document that is never connected — so no script runs and no resource loads during parsing. - Allowlist, not denylist. Only known-safe elements and attributes survive. Everything else —
script,style,iframe,object,embed, SVG/MathML, everyon*handler,srcdoc, unknown tags — is removed. A denylist can't keep up with parser quirks; an allowlist can. - Same URL guard as the core.
href/src/citeare scheme-checked with the sameisSafeUrlthe Zoijs renderer uses, sojavascript:anddata:text/htmlcan't slip through — the decision can never drift from the rest of the framework. - Reverse-tabnabbing guard.
target="_blank"links getrel="noopener noreferrer".
Because it returns live DOM nodes (not a string), there is no HTML sink for the browser to re-parse — the safe path stays the only path.
What survives#
Elements — text and structural content only:
a abbr address article aside b bdi bdo blockquote br caption cite code col colgroup dd del details dfn div dl dt em figcaption figure footer h1–h6 header hgroup hr i img ins kbd li main mark nav ol p pre q rp rt ruby s samp section small span strong sub summary sup table tbody td tfoot th thead time tr u ul var wbr
Attributes — class, id, title, dir, lang, role, any aria-* / data-*, plus per-element ones like href, src, alt, colspan, datetime, cite. The style attribute is dropped (CSS is an injection surface — bind style through Zoijs's object form instead).
Anything not on these lists is removed.
The API#
| Function | Purpose |
|---|---|
sanitize(dirty) | Sanitize an untrusted HTML string → Node[] (safe DOM nodes). null / undefined / "" → []. |
That's the whole package — one function.
Server rendering#
sanitize() returns live DOM nodes, so it is a client helper — it needs a browser DOM and throws without one. Sanitized content is therefore rendered on the client (it hydrates after load); it is not part of @zoijs/ssr string output. If you need sanitized rich text in your server-rendered HTML for SEO, sanitize to a string with a server-side tool in your render pipeline and place it in your shell yourself.
Threat model & limits#
This is a conservative allowlist sanitizer for rich text you broadly trust (your own markdown/CMS pipeline). It forbids foreign content (SVG/MathML) and raw-text elements, which removes the common mutation-XSS classes, and it reuses the core's vetted URL/attribute predicates.
It is not a drop-in replacement for a dedicated, independently-audited sanitizer when the input is fully adversarial and the stakes are high. In that case use a specialized, security-audited library (e.g. DOMPurify) and treat that boundary as security-critical — the same advice the Security guide gives.
Never assign untrusted data to innerHTML yourself: that bypasses Zoijs (and this package) entirely.