Skip to content

Text & HTML safety

Everything the library renders is treated as plain text by default. Markup is opt-in, one value at a time, through the html() marker.

ts
{
  fields: [
    {
      name: 'plain',
      kind: 'checkbox',
      label: 'Rendered literally, tags and all: <b>terms</b>',
    },
    {
      name: 'marked',
      kind: 'checkbox',
      label: html('Rendered as markup: <b>terms</b>'),
    },
  ],
}
Rendered form

Why it works this way

v1 assigned caller-supplied strings to innerHTML in 21 places — labels, messages, option text, checkbox captions, entry summaries. Any of those carrying user data was an XSS vector, and nothing in the API signalled that.

v2 inverts the default. setValue-style content goes through textContent unless it is explicitly wrapped, so the unsafe thing is the one you have to ask for.

Where TextOrHtml is accepted

displayName, tooltip text, checkbox label, message, cards[].content, select option label, and the return value of renderEntry.

Two ways to mark HTML

Tagged template — escapes interpolations

This is the form you want for anything data-driven. Literal parts are trusted, interpolated values are escaped:

ts
const name = '<img src=x onerror=alert(1)>';

html`<b>${name}</b>`;
// → '<b>&lt;img src=x onerror=alert(1)&gt;</b>'

Nested SafeHtml passes through unescaped, so you can compose:

ts
html`<p>${html('<em>note</em>')}: ${userText}</p>`;

Plain call — trusts the whole string

Use only for markup you fully control:

ts
html('<span class="dl-option-tag">Offline</span>');

Never wrap unvalidated input

html(someUserString) reintroduces exactly the vulnerability the default prevents. If any part of the string comes from user data, a database, or an API, use the tagged-template form.

A practical example

Rendering an entry summary that includes a user-supplied name:

ts
// Unsafe — do not do this.
renderEntry: (entry) => html(`<b>${String(entry['name'])}</b>`),

// Safe — the name is escaped.
renderEntry: (entry) => html`<b>${entry['name']}</b>`,

// Also safe — no markup needed at all.
renderEntry: (entry) => String(entry['name']),

Escaping manually

ts
import { escapeHtml } from 'declarative-forms';

escapeHtml(`&<>"'`); // '&amp;&lt;&gt;&quot;&#39;'

Checking a value

ts
import { isSafeHtml } from 'declarative-forms';

isSafeHtml(html('<b>x</b>')); // true
isSafeHtml('<b>x</b>'); // false

The marker uses a global symbol, so values still register as safe when two copies of the library end up in one bundle.

What this does not cover

  • custom fields. You own that DOM. If you assign innerHTML inside render, escaping is your responsibility — prefer textContent.
  • Attribute values. className, accept, type and similar are passed through as-is; do not build them from user input.
  • URLs. A file field stores whatever URL your persistFile returns; the library does not validate the scheme. Reject javascript: on your side.

Released under the MIT License.