Skip to content

feat(mf2): rewrite img to links and demote headings in sanitizeHtml - #425

Merged
davidwkeith merged 2 commits into
mainfrom
claude/issue-413-implications-ul9lgj
Jul 24, 2026
Merged

feat(mf2): rewrite img to links and demote headings in sanitizeHtml#425
davidwkeith merged 2 commits into
mainfrom
claude/issue-413-implications-ul9lgj

Conversation

@davidwkeith

Copy link
Copy Markdown
Owner

Summary

Takes the two information-preserving middle-ground increments out of #413 — the pieces that widen what stored replies can convey without widening the trust surface. Full <img> embedding (which implies a proxy/cache subsystem and a moderation story) stays out of scope, and #413 remains open for that.

@dwk/mf2's sanitizeHtml now rewrites two element kinds it previously unwrapped/dropped:

  • <img><a href="src">alt</a>. The src goes through the same absolute-http(s) validation as any a[href] (entity-obfuscated schemes rejected, resolved against baseUrl), and the resulting link gets the same forced rel="ugc nofollow". The link text is the decoded alt (re-encoded for emission), falling back to the resolved URL when alt is empty; the label counts toward maxTextLength like any other text. Rationale: an embedded image auto-fetches on every render of a stored snapshot — a read-receipt beacon to attacker-controlled infrastructure — so the fetch is deferred to a reader's click. This also fixes the silent-empty-content behavior: a photo-only reply previously sanitized to "" and was stored with no content at all. An <img> with no safe src keeps only its alt text; with neither, it is dropped.
  • h1h6<p><strong>…</strong></p>. A reply keeps its emphasis (nested inline formatting still flows through) but can never claim a slot in — or out-rank — the embedding page's own heading hierarchy.

Supporting changes: the open-tag stack now carries full closing markup (a demoted heading closes two tags, including via source-left-unclosed and truncation paths), and text emission/truncation is factored into one helper shared by text chunks and img labels. Spec (spec/packages/mf2.md), package README, and the sanitize.ts doc header updated to match. @dwk/webmention mention enrichment and @dwk/microsub timelines pick up the behavior at capture time with no code change.

Closes nothing; refs #413 and follows up #412.

Packages affected

@dwk/mf2 (code); @dwk/webmention, @dwk/microsub (behavior via changeset patch bumps, no code change)

Checklist

  • Read the relevant spec(s) under spec/packages/ and updated them if
    behaviour changed
  • Added/updated colocated tests (src/*.test.ts)
  • Ran the local CI gate: pnpm lint && pnpm format:check && pnpm typecheck && pnpm build && pnpm test
  • Added a changeset (pnpm changeset) if this touches a publishable
    package
  • Updated catalog.json / conformance/status.json — not applicable, no new worker and no conformance status change

🤖 Generated with Claude Code

https://claude.ai/code/session_01LRXFygnnhMusAV8WB7JJfS


Generated by Claude Code

Two information-preserving rewrites in the allowlist sanitizer (#413),
taking the middle ground that widens what stored replies can convey
without widening the trust surface:

- <img> becomes <a href="src">alt</a> with the same href validation and
  forced rel="ugc nofollow" as any link. An embedded image auto-fetches
  on every render of a stored snapshot — a tracking/egress vector — so
  the fetch is deferred to a reader's click instead. A photo-only reply
  no longer sanitizes to empty content. Without a safe src, only the alt
  text survives; without either, the img is dropped.
- h1-h6 demote to <p><strong> bold paragraphs, so a reply's markup keeps
  its emphasis but can never claim a slot in the embedding page's
  document outline.

Full <img> embedding (proxy/cache) stays out of scope per #413.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRXFygnnhMusAV8WB7JJfS

Copy link
Copy Markdown
Owner Author

CI status note: both real gates are green on 624920eCI (lint → format:check → typecheck → build → test, 2804 tests) and Conformance (release-gate + integration). The one red check, github-advanced-security ("Code scanning AI findings"), failed on GitHub's side before analyzing the diff: its Copilot backend rejected the scan agent's own model with model_not_supported (claude-opus-4.6, HTTP 400). Nothing in this PR触 triggers it and the run can't be retried via the API (403 "This workflow run cannot be retried"), so it needs either a re-push or GitHub fixing the model config on their end. It will re-run automatically on the next push to the branch, if any.


Generated by Claude Code

@davidwkeith davidwkeith left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review pass.

CONTRIBUTING.md conformance: checked out the branch and ran the local CI gate (pnpm lint && pnpm format:check && pnpm typecheck && pnpm build && pnpm test, scoped to @dwk/mf2) — all green (35/35 tests, no lint/format violations). PR title and changeset follow the required Conventional Commits + Changesets conventions, and the unchecked catalog/conformance checklist item carries a valid one-line reason per the contributing guide.

Correctness: one real issue found and verified — see the inline comment on the <img><a> rewrite. It produces nested <a> tags (invalid HTML) when a photo is already wrapped in a link in the source, which is a common microformats pattern. Everything else in the diff (heading demotion, text-length accounting through the shared emitText helper, entity re-encoding, dropped-subtree handling) checked out correctly against both the tests and manual tracing.


Generated by Claude Code

Comment thread packages/mf2/src/sanitize.ts
Rewriting <img> to <a> produced invalid nested anchors for the common
linked-photo pattern (<a href="full"><img src="thumb"></a>); browsers
auto-close the outer anchor at the inner one, mangling surrounding
structure. Track open-link depth and emit just the escaped label text
for an <img> inside an open <a>. An <img> whose wrapping link was
unwrapped (unsafe href) still becomes a link itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LRXFygnnhMusAV8WB7JJfS
@davidwkeith
davidwkeith merged commit 427064c into main Jul 24, 2026
8 of 10 checks passed
@davidwkeith
davidwkeith deleted the claude/issue-413-implications-ul9lgj branch July 24, 2026 04:52
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.

2 participants