Skip to content

fix: stop destroying anchors that carry data-* attributes - #257

Merged
jonathanKingston merged 1 commit into
mainfrom
claude/link-decorator-data-attrs-5981d7
Sep 3, 2026
Merged

fix: stop destroying anchors that carry data-* attributes#257
jonathanKingston merged 1 commit into
mainfrom
claude/link-decorator-data-attrs-5981d7

Conversation

@jonathanKingston

Copy link
Copy Markdown
Collaborator

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 removed data-browser-link / data-workspace-link from 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_RE is a module-private const with no config path — yet docs/ARCHITECTURE.md told hosts they "must also widen SAFE_OUTER_TAG_RE to match", which was never actionable.

Under htmlPolicy: 'escape' / 'escape-all', a host linkDecorator emitting 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:

<p>See &lt;a href=&quot;…&quot; data-browser-link=&quot;true&quot;&gt;the docs</a> here.</p>

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.ts claimed "identical posture to the DOMPurify backend". It wasn't:

input:      <a href="https://evil.test" data-workspace-link="true" data-anything="x">y</a>
            (allowedAttr: ['href'] — neither attr allowlisted)

DOMPURIFY : <a href="https://evil.test" data-workspace-link="true" data-anything="x">y</a>
NATIVEWALK: <a href="https://evil.test">y</a>

The DOMPurify backend never set ALLOW_DATA_ATTR: false, and DOMPurify defaults it to true, so every data-* passed regardless of the allowlist. The native walk did strict set membership. This is the real reason hosts needed a sanitizeExtension for #146's attributes — and it was invisible to a suite that runs only under jsdom + DOMPurify.

How

  • 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.tsnarrowAnchor backstops 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.tsdata-* passes the sink generically, matching DOMPurify's default so the backends are genuinely interchangeable. The four now-redundant data- entries are dropped from ALLOWED_ATTR.

Reviewer notes

Security posture is unchanged or tightened. narrowAnchor rebuilds the tag from the allowlist, so unknown attributes are dropped rather than forwarded to the sink, and isSanctionedRendererTag still 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:

  1. A raw prose <a href="…" style="…"> now 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 — but it is a change.
  2. 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.onElement already runs for every kept element and can re-narrow. The caveat and a recipe are documented on SanitizeExtension.

data-* is deliberately spelled twice. A regex literal in escape.ts (it tree-shakes out of entries wanting only escapeHtml; a constructed new RegExp does not, even with @__PURE__ — that cost +173 B gzipped on ./highlighters/shiki), and DATA_ATTR_NAME_SOURCE for the sink. Two copies of an attribute allowlist drifting apart is exactly what caused this bug, so data-attributes.test.ts pins them against each other behaviourally across 13 candidate names.

sanitize-backend.test.ts changed. It pinned the old stripping behaviour via a full-string strictEqual; split so the event-handler guarantee stays its own sharp assertion and the data-* change is explicit.

Follow-up this unblocks: the in-app remoteArtifactSanitizeExtension can drop its allowedAttr widening — there's a test asserting a host decorator's attributes now survive with no sanitizeExtension at all.

Verification

  • npm test — 1267 tests, 0 failures
  • npm run typecheck — clean
  • npm run coverage:ci — 99.98% lines, baseline held (+0.00%)
  • npm run size — green. ./sanitizers/browser 555 → 607 B gzipped for the new check; only that budget is bumped (555/607 verified against a clean HEAD build, so no unrelated regression is absorbed). ./highlighters/shiki returns to baseline exactly.
  • ReDoS: 560 KB of adversarial near-miss input matches in ~2.4 ms, linear.

🤖 Generated with Claude Code

#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 &lt;a href=&quot;…&quot; data-browser-link=&quot;true&quot;&gt;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
jonathanKingston marked this pull request as ready for review September 3, 2026 00:13
@jonathanKingston
jonathanKingston merged commit cfe1660 into main Sep 3, 2026
6 checks passed
@jonathanKingston
jonathanKingston deleted the claude/link-decorator-data-attrs-5981d7 branch September 3, 2026 00:19
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant