From f6109edd2924df1c4bef9a2b74c65911db91fae7 Mon Sep 17 00:00:00 2001 From: Jonathan Kingston Date: Thu, 3 Sep 2026 01:09:33 +0100 Subject: [PATCH] fix: stop destroying anchors that carry data-* attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #146 evicted the host-specific `data-browser-link` / `data-workspace-link` names from the escape gate (`SAFE_OUTER_TAG_RE`) and the sink allowlist in one commit, but only the sink got a replacement hook (`sanitizeExtension`). Under `htmlPolicy: 'escape'`/`'escape-all'` a host `linkDecorator` emitting those attributes therefore had its whole `` open tag escaped to literal text — and because `` matches a separate arm of the same regex the close tag survived, so the output was malformed rather than merely escaped:

See <a href="…" data-browser-link="true">the docs here.

- src/escape.ts: the `` arm matches any `data-*` attribute (value optional) by shape rather than by a list of host names, so no host's attribute names return to the neutral core. - src/escape.ts: `narrowAnchor` backstops that shape test, which was all-or-nothing and failed badly rather than safely. An anchor it does not recognise now degrades to its allowlisted attributes instead of being escaped whole. This fixes the class, not the instance: the anchor arm was the only strict one (``/``/``/`` already take `[^>]*`), so any attribute the core or a host adds later would have hit the same wall. The tag is rebuilt from the allowlist — unknown attributes are dropped, not forwarded to the sink — and `isSanctionedRendererTag` still rejects event handlers and dangerous schemes. - src/sanitize-browser.ts, src/sanitize.ts: `data-*` passes the sink generically, matching DOMPurify's `ALLOW_DATA_ATTR` default. The native Sanitizer walk was stripping every data attribute the DOMPurify backend kept, so the two shipped backends disagreed despite claiming identical posture — a divergence invisible to a suite that runs only under jsdom + DOMPurify, and the real reason hosts needed a `sanitizeExtension` for #146's attributes. The four now-redundant `data-` entries are dropped from ALLOWED_ATTR. Behaviour changes: - A raw prose `` renders as a narrowed link instead of literal text under `escape`. A plain raw `` already rendered live, so this makes the outcome consistent rather than attribute-dependent. - `data-*` now survives the sink on the native backend. It is inert in HTML but not on a page running htmx/Alpine/Stimulus, which bind to it; that caveat and an `onElement` re-narrowing recipe are documented on `SanitizeExtension`. The `data-*` shape is deliberately spelled twice — a regex literal in escape.ts, which tree-shakes out of entries wanting only `escapeHtml` where a constructed `new RegExp` does not, and `DATA_ATTR_NAME_SOURCE` for the sink. Two copies of an attribute allowlist drifting apart is what caused this bug, so data-attributes.test.ts pins the two against each other behaviourally. ./sanitizers/browser grows 555 → 607 B gzipped for the new check; only that budget is bumped. Co-Authored-By: Claude Opus 5 --- docs/ARCHITECTURE.md | 13 +++- scripts/bundle-size-budget.json | 2 +- src/data-attributes.test.ts | 39 +++++++++++ src/data-attributes.ts | 24 +++++++ src/escape.ts | 88 +++++++++++++++++++++-- src/raw-html-escape.test.ts | 100 +++++++++++++++++++++++++++ src/sanitize-backend.test.ts | 13 +++- src/sanitize-browser.ts | 11 ++- src/sanitize-data-attributes.test.ts | 72 +++++++++++++++++++ src/sanitize.ts | 40 ++++++++--- 10 files changed, 377 insertions(+), 25 deletions(-) create mode 100644 src/data-attributes.test.ts create mode 100644 src/data-attributes.ts create mode 100644 src/sanitize-data-attributes.test.ts 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 (`