fix: stop destroying anchors that carry data-* attributes - #257
Merged
Conversation
#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 `<a …>` open tag escaped to literal text — and because `</a>` matches a separate arm of the same regex the close tag survived, so the output was malformed rather than merely escaped: <p>See <a href="…" data-browser-link="true">the docs</a> here.</p> - src/escape.ts: the `<a>` 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 (`<code>`/`<em>`/`<strong>`/`<img>` 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 `<a href="…" style="…">` renders as a narrowed link instead of literal text under `escape`. A plain raw `<a href="…">` 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 <noreply@anthropic.com>
jonathanKingston
marked this pull request as ready for review
September 3, 2026 00:13
jonathanKingston
added a commit
to copse-dev/agent-pane
that referenced
this pull request
Sep 3, 2026
## Summary Bumps `@copse/streaming-markdown` from 1.0.8 to 1.1.0, the latest published version. **What changed upstream** ([v1.1.0 release](https://github.com/copse-dev/streaming-markdown/releases/tag/v1.1.0), [full diff](copse-dev/streaming-markdown@v1.0.8...v1.1.0)): - Fix: stop destroying anchors that carry `data-*` attributes (copse-dev/streaming-markdown#257) - Chores: dompurify and @types/react-dom bumps in the npm-minor-patch group Peer dependencies are identical between 1.0.8 and 1.1.0, so the lockfile change is confined to the one package entry. ## Verification - `pnpm run typecheck` passes for both node and web configs - `pnpm test -- renderer/markdown renderer/views/reasoning-display renderer/styles/accent-rails` passes (45 tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two related defects, both fallout from #146's split of host attributes out of the neutral core.
1. Anchors carrying
data-*were destroyed, not just escaped. #146 removeddata-browser-link/data-workspace-linkfrom the escape gate (SAFE_OUTER_TAG_RE) and the sink allowlist in one commit, but only the sink got a replacement hook (sanitizeExtension).SAFE_OUTER_TAG_REis a module-private const with no config path — yetdocs/ARCHITECTURE.mdtold hosts they "must also widenSAFE_OUTER_TAG_REto match", which was never actionable.Under
htmlPolicy: 'escape'/'escape-all', a hostlinkDecoratoremitting those attributes had its whole<a …>open tag escaped to literal text. Because</a>matches a separate arm of the same regex it survived, so the output was malformed, not merely escaped:passthrough(the default) was unaffected, so this only bit hosts opting into escape mode. Core footnote anchors are unaffected — they don't traverse the inline escape pass.2. The two sanitizer backends disagreed about
data-*.sanitize-browser.tsclaimed "identical posture to the DOMPurify backend". It wasn't:The DOMPurify backend never set
ALLOW_DATA_ATTR: false, and DOMPurify defaults it totrue, so everydata-*passed regardless of the allowlist. The native walk did strict set membership. This is the real reason hosts needed asanitizeExtensionfor #146's attributes — and it was invisible to a suite that runs only under jsdom + DOMPurify.How
src/escape.ts— the<a>arm matches anydata-*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—narrowAnchorbackstops that shape test. An anchor it doesn't 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 (<code>/<em>/<strong>/<img>already take[^>]*), so any attribute the core or a host adds later would have hit the same wall.src/sanitize-browser.ts,src/sanitize.ts—data-*passes the sink generically, matching DOMPurify's default so the backends are genuinely interchangeable. The four now-redundantdata-entries are dropped fromALLOWED_ATTR.Reviewer notes
Security posture is unchanged or tightened.
narrowAnchorrebuilds the tag from the allowlist, so unknown attributes are dropped rather than forwarded to the sink, andisSanctionedRendererTagstill rejects event handlers and dangerous schemes. An unquoted<a href=javascript:…>still escapes whole (no quoted href for the scheme check to read). Tests pin all of this.Two behaviour changes worth a look:
<a href="…" style="…">now renders as a narrowed link instead of literal text underescape. A plain raw<a href="…">already rendered live, so this makes the outcome consistent rather than attribute-dependent — but it is a change.data-*now survives the sink on the native backend. It's inert in HTML, but not on a page running htmx (data-hx-get), Alpine (data-x-on:click) or Stimulus (data-controller) — those bind to it, which hands model-authored content a live wire. No new API needed:sanitizeExtension.onElementalready runs for every kept element and can re-narrow. The caveat and a recipe are documented onSanitizeExtension.data-*is deliberately spelled twice. A regex literal inescape.ts(it tree-shakes out of entries wanting onlyescapeHtml; a constructednew RegExpdoes not, even with@__PURE__— that cost +173 B gzipped on./highlighters/shiki), andDATA_ATTR_NAME_SOURCEfor the sink. Two copies of an attribute allowlist drifting apart is exactly what caused this bug, sodata-attributes.test.tspins them against each other behaviourally across 13 candidate names.sanitize-backend.test.tschanged. It pinned the old stripping behaviour via a full-stringstrictEqual; split so the event-handler guarantee stays its own sharp assertion and thedata-*change is explicit.Follow-up this unblocks: the in-app
remoteArtifactSanitizeExtensioncan drop itsallowedAttrwidening — there's a test asserting a host decorator's attributes now survive with nosanitizeExtensionat all.Verification
npm test— 1267 tests, 0 failuresnpm run typecheck— cleannpm run coverage:ci— 99.98% lines, baseline held (+0.00%)npm run size— green../sanitizers/browser555 → 607 B gzipped for the new check; only that budget is bumped (555/607 verified against a cleanHEADbuild, so no unrelated regression is absorbed)../highlighters/shikireturns to baseline exactly.🤖 Generated with Claude Code