@marianmeres/safe-html
v0.4.0
Published
[](https://www.npmjs.com/package/@marianmeres/safe-html) [](https://jsr.io/@marianmeres/safe-html) [ are rejected the first
time the template renders, whatever the data.
Zero dependencies. Runtime-agnostic ESM (Deno, Node, Bun, browsers). For servers that render HTML strings without a framework.
Installation
deno add jsr:@marianmeres/safe-htmlnpm install @marianmeres/safe-htmldeno fmt reformats the contents of html-tagged templates, which changes your output. Start
template files with // deno-fmt-ignore-file (see Notes).
Usage
import { html, jsonScript } from "@marianmeres/safe-html";
interface Link {
label: string;
href: string;
}
const LinkItem = (link: Link) => html`<li><a href="${link.href}">${link.label}</a></li>`;
export const page = (title: string, links: Link[], ld: object) =>
html`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>${title}</title>
<script type="application/ld+json">${jsonScript(ld)}</script>
</head>
<body>
<h1>${title}</h1>
${links.length > 0 ? html`<ul>${links.map(LinkItem)}</ul>` : html`<p>No links.</p>`}
</body>
</html>`;
// String(page(…)) is the document.
// label "<img src=x onerror=alert(1)>" → rendered as text
// href "javascript:alert(1)" → rendered as href="about:invalid#blocked"Components are plain functions returning html. Conditionals, lists and nesting are plain
expressions:
import { attrs, html, join, scriptText, srcset, styleText, url } from "@marianmeres/safe-html";
html`<p>${user.name}</p>`; // text: escaped
html`<p title="${note}" class="card ${kind}">…</p>`; // quoted attribute: escaped
html`<a href="${link}">…</a>`; // URL: scheme allowlist, then escaped
html`<img src="${cdn}/img/${file}.png">`; // URL slot followed by "/", "?" or "#"
html`<a href="/items/${id}?tab=${tab}">…</a>`; // static prefix settles the scheme
html`<button ${attrs({ type: "submit", disabled: busy })}>`; // attribute list
html`<img srcset="${srcset([{ url: a }, { url: b, descriptor: "2x" }])}">`;
html`<script>const data = ${jsonScript(data)};</script>`; // data island
html`<script>${scriptText(source)}</script>`; // inline code
html`<style>${styleText(css)}</style>`; // inline CSS
html`<p>${join(tags, ", ")}</p>`; // separated list
html`<ul>${items.map((i) => html`<li>${i}</li>`)}</ul>`; // arrays render item by item
html`${loggedIn && html`<a href="/logout">Log out</a>`}`; // false/null/undefined render nothing
html`<img src="${url(dataUri, { schemes: ["data"] })}">`; // allow a scheme for one valueExamples
Four runnable servers under example/, smallest first — a single value in four
slots, a small blog, a URL policy, and a server-rendered app enhanced in the browser by
@marianmeres/vanilla that still works with
JavaScript disabled. Each renders hostile data on purpose, and each takes --print to dump
its HTML to stdout.
deno run --allow-net example/01-hello.ts # http://localhost:8801/
deno run example/02-blog.ts --print # just the bytesSee example/README.md for the index.
What is rejected
html`<a href=${u}>`; // HtmlTemplateError: unquoted attribute value
html`<button onclick="${js}">`; // HtmlTemplateError: slot in event-handler attribute
html`<${tag}>`; // HtmlTemplateError: slot in tag name
html`<a href="${base}${path}">`; // HtmlTemplateError: URL slot must be alone (or continue with / ? #)
html`<a href="http${s}://x">`; // HtmlTemplateError: ambiguous scheme
html`<a href="javascript:go('${id}')">`; // HtmlTemplateError: static scheme not allowed
html`<!-- ${note} -->`; // HtmlTemplateError: slot in comment
html`<div class="x">${a}<b title="`; // HtmlTemplateError: template ends inside a tag
html`<svg><style>${styleText(css)}</style></svg>`; // HtmlTemplateError: not raw text inside SVG
html`<noscript><script>${jsonScript(d)}</script></noscript>`; // HtmlTemplateError: script slot in <noscript>
html`<select><style>…</style></select>`; // HtmlTemplateError: older parsers ignore <style> there
html`<script>let n = ${n};</script>`; // HtmlValueError: script takes jsonScript()/scriptText()
html`<p title="${html`<b>x</b>`}">`; // HtmlValueError: html fragment in an attribute value
html`<p>${new Date()}</p>`; // HtmlValueError (and a type error)Template errors depend only on the template, never on data, so rendering each template once in a test surfaces them all. Value errors depend on a value's type, never on string content: a crafted string in your database can't turn a page into a 500. Error messages never include values.
Safety
Guaranteed. Given templates that pass analysis and no unsafeRaw(), no string value can:
- create an element, attribute, comment or declaration;
- close an element or attribute value that the template opened;
- inject script through an event-handler attribute (those slots are rejected);
- put a URL with a scheme outside the allowlist into a URL attribute (default:
http,https,mailto,tel), including throughsrcsetorattrs(); - break out of
<script>,<style>,<title>,<textarea>or<noscript>content; - change how SVG/MathML content parses.
This is tested against parse5, a spec-compliant HTML parser: templates rendered with adversarial strings (XSS filter-evasion vectors plus seeded fuzzing) must parse to the same tree as with benign values.
Not guaranteed:
- CSS injection inside
style="…"orstyleText(). A value can't break out, but it can restyle the page or load aurl(…). Build style values from validated tokens. - Semantic misuse:
<meta http-equiv="refresh" content="${x}">,<base href>pointing at a hostilehttps:host, open redirects, and//hostprotocol-relative URLs (relative, so allowed). - Attributes that other code later treats as URLs or code:
data-src,data-href, and client-side frameworks that evaluate attributes or text (Alpinex-*, htmxhx-on*, Vue/Angular template syntax in server-rendered markup). Don't interpolate data there. - DOM clobbering through data-controlled
idornamevalues. - A slot inside a JavaScript string literal in a
<script>.jsonScript()output must be used as a whole JS expression. - URL component encoding:
href="/search?q=${q}"is HTML-escaped, notencodeURIComponent-ed. That is your job. - Anything passed through
unsafeRaw()— it means trusted, never cleaned. This is not an HTML sanitizer: to accept HTML from users, sanitize it elsewhere first. - Character encoding. Serve
content-type: text/html; charset=utf-8and emit<meta charset="utf-8">.
The full rules are in docs/design.md.
URL policy
The default exports use a default kit. Create your own for a different policy:
import { createHtml, DEFAULT_URL_SCHEMES } from "@marianmeres/safe-html";
export const { html, attrs, url, srcset } = createHtml({
urlSchemes: [...DEFAULT_URL_SCHEMES, "sms"],
blockedUrl: "#blocked",
onBlockedUrl: ({ url, tag, attribute }) =>
log.warn("blocked URL", { tag, attribute }),
});Porting from Svelte templates
| Svelte | Here |
| ------------------------------ | -------------------------------------------------- |
| {expr} | ${expr} |
| {#if c}A{:else}B{/if} | ${c ? html`A` : html`B`} |
| {#if c}A{/if} | ${c && html`A`} (careful when c is a number) |
| {#each xs as x}…{/each} | ${xs.map((x) => html`…`)} |
| {#snippet row(x)}…{/snippet} | const row = (x: T) => html`…` / ${row(x)} |
| <Card {...props} /> | ${Card(props)} |
| <div {...rest}> | <div ${attrs(rest)}> |
| class:active={on} | class="${on ? "active" : ""}" |
| {@html jsonLd} in <script> | ${jsonScript(data)} |
| {@html css} in <style> | ${styleText(css)} |
| {@html anythingElse} | ${unsafeRaw(s)} (review every one) |
| {#await} | not supported; await the data before rendering |
Differences in the output:
- HTML comments are emitted. Svelte strips
<!-- … -->from SSR output;htmlkeeps them, so a comment in a template ships to every visitor. Move rationale into a JS comment (e.g. the docblock of the function that returns the fragment). - Boolean attributes: Svelte writes
selected="",attrs({ selected: true })writesselected. Same DOM, different bytes. - Quotes in text: Svelte writes
'and"in element text as is; here they are'and"in every context (see docs/design.md §5.1). Same DOM.
Tests that pin markup bytes need adjusting for these; tests that compare parsed text don't.
Notes
- Booleans render nothing,
trueincluded (as in JSX). Numbers render, so${count && html`…`}prints0for zero: writecount > 0 && …. - An untagged template literal around a fragment (
`<p>${frag}</p>`) makes a plain string, whichhtmlescapes: visibly double-escaped, never an injection. - Every attribute value that contains a slot must be quoted.
<noscript>content works like any other markup, except that it can't hold<script>or<style>slots, orhtmlfragments that have them (with scripting enabled, the element ends at the first</noscript, even one inside a script string).<select>can't contain<title>,<style>,<svg>,<math>,<template>or the other tags that older parsers ignore there (their content would be parsed as markup), norhtmlfragments that have them. Options, optgroups, text and<script>are fine.attrs()output starts with a space. When an attribute appears both statically and inattrs(), the first one in the tag wins (HTML parsing rules).- Whitespace is preserved exactly; nothing is minified.
deno fmtformats the contents ofhtml-tagged templates (whitespace,/>, line breaks), which changes your output. Add// deno-fmt-ignoreabove a statement (or// deno-fmt-ignore-file) where exact output matters.unsafeRawis long and loud on purpose, so every use is greppable. A codebase can assert in a test how many uses it has.
Performance
Analysis runs once per call site and is cached. A render is one pass of concatenation plus
escaping. Steady state on an Apple M2 (Deno 2.9, deno task bench):
| Benchmark | hand-written concatenation + manual escaping | safe-html | ratio | | ------------------------- | -------------------------------------------- | --------- | ----- | | 1 000-row table | 141 µs | 264 µs | ~1.9× | | small page (31 templates) | 2.7 µs | 5.5 µs | ~2.0× |
The safe-html side does more work: every URL is scheme-checked and every fragment is a branded value.
API
See API.md for the complete API.
