feat(mf2): rewrite img to links and demote headings in sanitizeHtml - #425
Conversation
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
|
CI status note: both real gates are green on 624920e — CI (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 Generated by Claude Code |
davidwkeith
left a comment
There was a problem hiding this comment.
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
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
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'ssanitizeHtmlnow rewrites two element kinds it previously unwrapped/dropped:<img>→<a href="src">alt</a>. Thesrcgoes through the same absolute-http(s)validation as anya[href](entity-obfuscated schemes rejected, resolved againstbaseUrl), and the resulting link gets the same forcedrel="ugc nofollow". The link text is the decodedalt(re-encoded for emission), falling back to the resolved URL whenaltis empty; the label counts towardmaxTextLengthlike 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 safesrckeeps only itsalttext; with neither, it is dropped.h1–h6→<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 thesanitize.tsdoc header updated to match.@dwk/webmentionmention enrichment and@dwk/microsubtimelines 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
spec/packages/and updated them ifbehaviour changed
src/*.test.ts)pnpm lint && pnpm format:check && pnpm typecheck && pnpm build && pnpm testpnpm changeset) if this touches a publishablepackage
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