diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ac47a3d..f2dd0b7 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -145,8 +145,17 @@ When extending the renderer or its CSS, preserve these rules:
per-element gate so a host's injected markup (e.g. its artifact ``) survives
sanitization. The core allowlist stays the security gate; keep additions narrow.
- A host emitting attributes outside the escape/sink allowlists must also widen
- `SAFE_OUTER_TAG_RE` (`escape.ts`) to match.
+ A host emitting attributes outside the sink allowlist widens it via
+ `sanitizeExtension`; that is the only allowlist a host has to touch. **`data-*`
+ needs no widening at all** — custom data attributes pass both gates
+ generically (`data-attributes.ts`), matching DOMPurify's `ALLOW_DATA_ATTR`
+ default so the two shipped backends stay interchangeable. A host on a page
+ running htmx/Alpine/Stimulus, where `data-*` is not inert, re-narrows with
+ `sanitizeExtension.onElement`. The pre-sink escape gate (`SAFE_OUTER_TAG_RE`,
+ `escape.ts`) is not configurable and matches decorator anchors by shape; an
+ anchor it does not recognise degrades to its allowlisted attributes
+ (`narrowAnchor`) rather than being escaped whole, which would leave the
+ matching `` behind as a stray close tag.
- **Valid block HTML.** Block elements (`
'), /<div /i)
+ })
+
+ it('does not mint an href-less anchor from a href-shaped attribute value', () => {
+ assert.match(escaped('y'), /<a /i)
+ })
+ })
})
diff --git a/src/sanitize-backend.test.ts b/src/sanitize-backend.test.ts
index 82b4445..7fd3788 100644
--- a/src/sanitize-backend.test.ts
+++ b/src/sanitize-backend.test.ts
@@ -91,12 +91,21 @@ describe('enforceSanitizerAllowlist (native backend narrowing)', () => {
assert.equal(root.innerHTML, '
keep
bare text')
})
- it('strips attributes outside the allowlist', () => {
- const root = parse('link')
+ it('strips attributes outside the allowlist, event handlers included', () => {
+ const root = parse('link')
enforceSanitizerAllowlist(root, config)
assert.equal(root.innerHTML, 'link')
})
+ // `data-*` is the deliberate exception: it passes generically, matching
+ // DOMPurify's `ALLOW_DATA_ATTR` default so the two backends agree
+ // (sanitize-data-attributes.test.ts pins that parity).
+ it('keeps data-* without an allowlist entry', () => {
+ const root = parse('link')
+ enforceSanitizerAllowlist(root, config)
+ assert.equal(root.innerHTML, 'link')
+ })
+
it('drops content of dangerous containers rather than unwrapping them', () => {
const root = parse('
ok
')
enforceSanitizerAllowlist(root, config)
diff --git a/src/sanitize-browser.ts b/src/sanitize-browser.ts
index d3c3a71..5d3861a 100644
--- a/src/sanitize-browser.ts
+++ b/src/sanitize-browser.ts
@@ -1,3 +1,4 @@
+import { DATA_ATTR_NAME_RE } from './data-attributes.ts'
import type { SanitizerBackend, SanitizerConfig } from './sanitize.ts'
// Zero-dependency sanitizer backend built on the native Sanitizer API
@@ -6,7 +7,8 @@ import type { SanitizerBackend, SanitizerConfig } from './sanitize.ts'
// scripts, event-handler attributes, and unsafe URLs); a strict allowlist walk
// then narrows the result to exactly the tags/attributes the renderer produces
// and runs the core/host per-element gate — identical posture to the DOMPurify
-// backend.
+// backend, `data-*` included (that walk once diverged by stripping every data
+// attribute DOMPurify's `ALLOW_DATA_ATTR` default kept).
// `setHTML` is a recent addition and may be missing from the ambient DOM lib.
// The options arg carries a Sanitizer config; `elements`/`attributes` are
@@ -65,7 +67,12 @@ export function enforceSanitizerAllowlist(root: ParentNode, config: SanitizerCon
continue
}
for (const attr of Array.from(el.attributes)) {
- if (!allowedAttr.has(attr.name.toLowerCase())) el.removeAttribute(attr.name)
+ // `data-*` passes generically, matching DOMPurify's `ALLOW_DATA_ATTR`
+ // default so the two shipped backends stay interchangeable — without it
+ // this walk silently stripped every host/renderer data attribute the
+ // DOMPurify backend kept (see DATA_ATTR_NAME_SOURCE for why generically).
+ const name = attr.name.toLowerCase()
+ if (!allowedAttr.has(name) && !DATA_ATTR_NAME_RE.test(name)) el.removeAttribute(attr.name)
}
config.onElement?.(el, tag)
}
diff --git a/src/sanitize-data-attributes.test.ts b/src/sanitize-data-attributes.test.ts
new file mode 100644
index 0000000..4cac0c9
--- /dev/null
+++ b/src/sanitize-data-attributes.test.ts
@@ -0,0 +1,72 @@
+// `data-*` passes both sanitizer backends generically rather than name-by-name
+// (DATA_ATTR_NAME_SOURCE). The native allowlist walk used to strip every data
+// attribute DOMPurify's `ALLOW_DATA_ATTR` default kept, so the two shipped
+// backends disagreed about host routing attributes (#146) and about the
+// renderer's own footnote markers — a divergence invisible to a suite that runs
+// only under jsdom + DOMPurify. These pin the parity.
+import '../tests/setup-dom-jsdom.ts'
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import { dompurifyBackend } from './sanitize-dompurify.ts'
+import { enforceSanitizerAllowlist } from './sanitize-browser.ts'
+import { renderMarkdown } from './renderer.ts'
+import { appLinkDecorator } from './host-workspace.ts'
+
+const CONFIG = { allowedTags: ['a', 'p'], allowedAttr: ['href'] }
+
+function nativeWalk(html: string): string {
+ const host = document.createElement('div')
+ host.innerHTML = html
+ enforceSanitizerAllowlist(host, CONFIG)
+ return host.innerHTML
+}
+
+describe('data-* sanitizer parity', () => {
+ const cases = [
+ ['host routing attributes (#146)', 'y'],
+ ['renderer footnote markers', 'y'],
+ ['an arbitrary custom attribute', 'y'],
+ ] as const
+
+ for (const [name, html] of cases) {
+ it(`keeps ${name} on both backends`, () => {
+ assert.equal(nativeWalk(html), dompurifyBackend.sanitize(html, CONFIG))
+ assert.match(nativeWalk(html), /data-/)
+ })
+ }
+
+ it('still strips a non-data attribute outside the allowlist on both', () => {
+ const html = 'y'
+ assert.equal(nativeWalk(html), dompurifyBackend.sanitize(html, CONFIG))
+ assert.doesNotMatch(nativeWalk(html), /style=|title=/)
+ })
+
+ it('carries a host decorator’s attributes through with no sanitizeExtension (#146)', () => {
+ const html = String(
+ renderMarkdown('[docs](https://example.com/page)', {
+ linkDecorator: appLinkDecorator,
+ htmlPolicy: 'escape',
+ }),
+ )
+ assert.match(html, /]*data-browser-link="true"[^>]*>/)
+ })
+
+ it('lets a host re-narrow data-* through onElement', () => {
+ const html = String(
+ renderMarkdown('[docs](https://example.com/page)', {
+ linkDecorator: appLinkDecorator,
+ htmlPolicy: 'escape',
+ sanitizeExtension: {
+ onElement: (node) => {
+ if (typeof node.getAttribute !== 'function') return
+ for (const { name } of Array.from(node.attributes)) {
+ if (name.startsWith('data-')) node.removeAttribute(name)
+ }
+ },
+ },
+ }),
+ )
+ assert.doesNotMatch(html, /data-/)
+ assert.match(html, /`/`
`) — presentational, no XSS surface.
'align',
// Task-list checkbox attributes (#614) — read-only booleans, no XSS surface.
@@ -96,13 +100,10 @@ const ALLOWED_ATTR = [
'id',
// GFM footnote / task-list accessibility hooks (#216/#217): `aria-label` on
// task checkboxes and backrefs, `aria-describedby` linking a ref to the
- // footnotes heading, and the `data-footnote*` semantic markers GitHub emits.
- // All presentational/semantic only — no XSS surface.
+ // footnotes heading. (The `data-footnote*` semantic markers GitHub emits need
+ // no entry — see the generic `data-*` note above.)
'aria-label',
'aria-describedby',
- 'data-footnotes',
- 'data-footnote-ref',
- 'data-footnote-backref',
]
/**
@@ -190,6 +191,23 @@ export function getSanitizerBackend(): SanitizerBackend | null {
* task-list ``), letting the host drop or lock down its own tags (e.g.
* remove any non-artifact `` and strip its `src`). Set it per render via
* `MarkdownConfig.sanitizeExtension`.
+ *
+ * **`data-*` caveat.** Custom data attributes pass generically (see
+ * `DATA_ATTR_NAME_SOURCE`), so no host needs an `allowedAttr` entry for one.
+ * That is safe because `data-*` is inert in HTML — but it is *not* inert on a
+ * page running a framework that binds to it: htmx (`data-hx-get`), Alpine
+ * (`data-x-on:click`) and Stimulus (`data-controller` / `data-action`) all give
+ * the namespace behaviour. A host mounting sanitized output into such a page is
+ * handing model-authored content a live wire, and should re-narrow with
+ * `onElement` — drop every `data-` attribute the host did not itself emit:
+ *
+ * ```ts
+ * onElement: (node) => {
+ * for (const { name } of Array.from(node.attributes)) {
+ * if (name.startsWith('data-') && !MINE.has(name)) node.removeAttribute(name)
+ * }
+ * }
+ * ```
*/
export interface SanitizeExtension {
allowedTags?: readonly string[]