sanitize-html

Rich-text allowlist sanitizer (auto-imported). HTML authored by operators in the admin panel (announcements, campaign copy, help text) must never go straight into v-html — run it through sanitizeHtml first.

<script setup lang="ts">
const safe = ref('')
onMounted(() => {
  safe.value = sanitizeHtml(notice.data)   // 🔴 client-only, see below
})
</script>

<template>
  <div v-html="safe" />
</template>

API

sanitizeHtml(html: string, options?: {
  allowedTags?: readonly string[]   // defaults to 13 common tags; must be a subset of SAFE_TAGS
  allowRelativeUrl?: boolean        // default true: a[href] / img[src] may use site-relative /paths
}): string

It rebuilds the DOM with DOMParser (not regex replacement): non-allowlisted tags are unwrapped (children preserved), dangerous tags are dropped whole, on* / style are always stripped, a gets target="_blank" + rel="noopener noreferrer nofollow", and img gets loading="lazy".

🔴 client-only

It depends on DOMParser (a browser API) and throws explicitly when called during SSR — rather than silently returning an empty string, which would turn "the announcement doesn't render" into a clueless mystery. Call it inside onMounted / an IS_CLIENT guard / <ClientOnly>, same discipline as decryptBinary and dbGet.

Do not hand-roll an isomorphic HTML tokenizer just to get SSR support. That is the exact opposite of the reasoning that makes this util safe to hand-write: the allowlist is narrow and auditable, whereas a tokenizer is broad and security-critical. If you genuinely need SSR sanitization, open an issue so it can be evaluated separately.

Why allowedTags must stay within SAFE_TAGS

You may narrow the allowlist (each site can trim it to its own product shape), but you cannot go beyond the SAFE_TAGS superset the framework maintains — out-of-bounds tags are ignored and named in a dev-only console.error.

The reason: this function returns a string, and the consumer's v-html parses it again. parse → serialize → re-parse is precisely the mXSS (mutation XSS) surface, and every known vector relies on elements with special parsing rules — foreign content (svg, math), raw text (style, xmp), escapable raw text (textarea, title), scripting-sensitive (noscript), plus template and table foster parenting.

SAFE_TAGS is curated on exactly one criterion — ordinary parsing rules — and contains none of the above. If arbitrary values were allowed, a site that adds table so operators can insert tables, or svg for layout, would reinstate the attack surface without realising it — and nothing would warn them.

To extend the superset, open a framework issue: it gets reviewed once and the whole ecosystem benefits.

// ✅ Narrowing: plain-text formatting only
sanitizeHtml(raw, { allowedTags: ['P', 'BR', 'B', 'STRONG'] })

// ❌ Out of bounds: SCRIPT / TABLE are not in SAFE_TAGS — ignored, with a dev error
sanitizeHtml(raw, { allowedTags: ['P', 'SCRIPT', 'TABLE'] })

allowRelativeUrl

Defaults to true: a[href] and img[src] consistently accept site-relative paths beginning with a single / (protocol-relative //evil.com and backslash bypasses /\evil.com are always rejected). Set it to false and both accept absolute http(s) only.

This switch governs a and img together. Earlier per-site implementations made img stricter than a (reasoning: "prevent path probing"); the framework version rejects that reasoning — an attacker who can inject HTML already has better probes, while the cost is that operator-inserted site-relative images get silently swallowed.

Notes

  • Don't write your own sanitizer in application code: getting it wrong produces no error at all — the XSS surface sits there silently, tests stay green and the page looks fine, until it is exploited.
  • Don't paper over empty output with ?? fallbacks: if the sanitized result is empty, first check whether the source HTML was empty to begin with.
  • Comment nodes and anything the parser places in <head> (title / meta / a leading style) are dropped.
  • Extremely deep nesting is truncated (depth limit 100) so it cannot tie up the main thread.