サニタイザーの許可リストをエクステンション駆動で動的構築する設計へ

basecamp/lexxy

LexxyのHTMLサニタイザーが、ハードコードされた許可リストから「エクステンションが自身の許可要素を宣言する」動的構築方式へと移行しました。これにより、新しいエクステンションを追加した際にサニタイザー設定を別途更新する必要がなくなります。

背景

従来のアーキテクチャでは、サニタイザーの許可タグ・属性がハードコードされており、エクステンション追加時に人間がその整合性を維持する必要がありました。#880 ではプリセットごとに allowedElements を設定できる機能が追加されましたが、これはエクステンション側ではなくアプリケーション側に設定の責務を課すアプローチでした。

本PRはその方針を転換し、許可リストを「インポート可能なタグ」と「エクステンションが明示的に宣言するタグ」の2つのソースから動的に構築します。具体的には、Lexicalの _htmlConversions マップに登録されたタグ(インポート時に変換ロジックが存在するもの)と、各エクステンションの allowedElements ゲッターが返すタグの合算が許可リストとなります。

この分離には明確な理由があります。WrappedTableNode を例にとると、このノードはエクスポート時に <figure> ラッパーを生成しますが、インポート時にLexicalは <figure> のコンバーターを持たず、内部の <table> を直接処理します。つまり「エクスポートするが _htmlConversions には現れない」タグが存在するため、エクステンション側での明示的な宣言機構が必要になります。

技術的な変更

動的許可リストの構築

buildConfig() 関数が allowedElements を引数に取るよう変更され、タグごとの許可属性マップ(tagAttributes)を動的に構築するようになりました。

変更前:

const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em",
  "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ]

const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
  "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
  "previewable", "sgid", "src", "style", "title", "url", "width" ]

export function buildConfig() {
  return {
    ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")),
    ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
    ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
    SAFE_FOR_XML: false
  }
}

変更後:

const ALLOWED_HTML_ATTRIBUTES = [ "class", "contenteditable", "href", "src", "style", "title" ]

export function buildConfig(allowedElements) {
  const tagAttributes = {}

  for (const element of allowedElements) {
    if (typeof element === "string") {
      tagAttributes[element] ||= []
    } else {
      tagAttributes[element.tag] ||= []
      tagAttributes[element.tag].push(...element.attributes)
    }
  }

  return {
    ALLOWED_TAGS: Object.keys(tagAttributes),
    ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
    ADD_ATTR: (attribute, tag) => tagAttributes[tag]?.includes(attribute),
    ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
    SAFE_FOR_XML: false
  }
}

allowedElements の各要素は、文字列(タグ名のみ)またはハッシュ({ tag, attributes } 形式)を受け付けます。DOMPurifyの ADD_ATTR コールバックを使い、タグごとに許可属性を判定することで、属性のスコープをタグ単位に絞り込んでいます。

エクステンションへの allowedElements ゲッター導入

基底クラス LexxyExtension に空配列を返すデフォルト実装が追加され、各エクステンションがオーバーライドして許可要素を宣言できるようになりました。

get allowedElements() {
  return []
}

AttachmentsExtension は添付ファイル固有の属性(sgidfilenamecontent-type など)を <attachment> タグにスコープして宣言します。

const ATTACHMENT_ATTRIBUTES = [ "alt", "caption", "content", "content-type", "data-direct-upload-id",
  "data-sgid", "filename", "filesize", "height", "presentation", "previewable", "sgid", "url", "width" ]

get allowedElements() {
  return [ { tag: ActionTextAttachmentNode.TAG_NAME, attributes: ATTACHMENT_ATTRIBUTES } ]
}

TablesExtension はエクスポートで生成するが _htmlConversions に現れない figure と、tbody を宣言します。

get allowedElements() {
  return [ "figure", "tbody" ]
}

エディタ側での許可リスト組み立て

LexicalEditorElement に2つのプライベートゲッターが追加されました。

get #allowedElements() {
  return this.#importableTags.concat(this.extensions.allowedElements)
}

get #importableTags() {
  const tags = Array.from(this.editor._htmlConversions.keys())
  return tags.filter(tag => !tag.startsWith("#"))
}

_htmlConversions のキーから #text などの内部ノード識別子(# で始まるもの)を除外し、実際のHTMLタグ名のみを取り出しています。

<span> のエクスポート時ストリッピング

Lexicalの TextNode.createDOM() はすべてのテキストランを <span> でラップしますが、これはエクスポートHTMLには不要なエディタ用クラスを運ぶだけです。spanexportTextNodeDOM 内でアンラップする unwrapSpans 関数が追加されました。

function unwrapSpans(element) {
  if (element.tagName === "SPAN") return element.firstChild

  for (const span of element.querySelectorAll("span")) {
    span.replaceWith(...span.childNodes)
  }

  return element
}

これにより span をサニタイザーで一律禁止する必要がなくなり、span を出力するエクステンションの可能性を閉じずに済みます。また、span と同じカスタムエクスポートパスを通過させるために、CodeHighlightNodeexportTextNodeDOM に登録されています。なお、figcaptionq_htmlConversions にも各エクステンションの宣言にも現れないため、今回の変更で自然に許可リストから脱落します。

設計判断

「誰が許可リストを管理するか」の責務をエクステンションに移した点が本PRの核心です。

#880 でアプリケーション側に allowedElements を設定させるアプローチが試みられましたが、これはエクステンション追加のたびにアプリケーション設定を更新する運用上の摩擦を生みます。本PRはエクステンション自身が allowedElements ゲッターを宣言する方式に切り替えることで、エクステンションを追加するだけで許可リストが自動的に拡張される仕組みを実現しています。

属性のスコープをタグ単位に絞ったことも重要な変更です。変更前は sgidfilename などの添付ファイル属性がグローバルに許可されていたため、任意のタグでこれらの属性が通過していました。変更後は ATTACHMENT_ATTRIBUTES<attachment> タグにのみ適用されるため、サニタイザーの許可範囲が最小権限に近づいています。

トレードオフとして、_htmlConversions はLexicalの内部APIであり、アンダースコアプレフィックスが示すように公開インターフェースではありません。このAPIへの依存はLexicalのバージョンアップによって壊れるリスクを持ちますが、PR内では「インポート可能なタグ」を自動的に取得できることの利便性が優先されています。

まとめ

この変更は、エクステンションとサニタイザーの結合を断ち切り、エクステンション追加時の設定コストをゼロに近づけることを目的としています。allowedElements ゲッターという単一の宣言点を設けることで、許可リストの管理がエクステンション内で完結し、エクステンション開発者が意識すべき表面積が明確に定義されました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
2c5b6d53

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文→背景→技術的な変更→設計判断→まとめ」という総論→各論→結論の構成が明確で、ガイドラインを完全に満たしています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```言語:ファイルパス)およびGitHubのPR番号リンク([#123](URL))の記法が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Lexicalやサニタイザーに関する専門的な内容が、過度な説明なく簡潔に記述されており、対象読者であるエンジニアに適切です。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各段落はトピックセンテンスで始まり、1段落1トピックの原則が守られています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のすべてのコードブロックは提供されたDiff情報と一致しており、ファイル名や変更内容も正確に引用されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「`_htmlConversions`」「`ADD_ATTR`コールバック」「ゲッター」など、PRや関連技術で使われる用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

許可リストの動的構築ロジックや、`span`タグのアンラップ処理など、技術的な変更に関する説明はDiffの内容と整合しており、論理的で正確です。

事実の突合 ⚠ WARNING

PR情報による主張の裏付け(ハルシネーション検出)

「設計判断」セクションでの`_htmlConversions`が内部APIであることのリスクに関する言及は、PRに明記されていないものの、コードから読み取れる妥当な技術的洞察です。しかし、厳密にはPR情報外の推測を含むため警告とします。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#909, #880)やその他の固有名詞が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題「Build sanitizer allowlist dynamically」を的確に要約し、より詳細なコンテキストを付与しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

バージョンサポート状況やリリース日程など、PR情報にない外部知識の追記はなく、提供された情報源に忠実です。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「従来の」「変更後」といった時間的関係を示す表現は、PRの文脈と一致しており、誤解を招く点はありません。