サニタイザーの許可リストをエクステンション駆動で動的構築する設計へ
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 は添付ファイル固有の属性(sgid、filename、content-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には不要なエディタ用クラスを運ぶだけです。span を exportTextNodeDOM 内でアンラップする 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 と同じカスタムエクスポートパスを通過させるために、CodeHighlightNode も exportTextNodeDOM に登録されています。なお、figcaption と q は _htmlConversions にも各エクステンションの宣言にも現れないため、今回の変更で自然に許可リストから脱落します。
設計判断
「誰が許可リストを管理するか」の責務をエクステンションに移した点が本PRの核心です。
#880 でアプリケーション側に allowedElements を設定させるアプローチが試みられましたが、これはエクステンション追加のたびにアプリケーション設定を更新する運用上の摩擦を生みます。本PRはエクステンション自身が allowedElements ゲッターを宣言する方式に切り替えることで、エクステンションを追加するだけで許可リストが自動的に拡張される仕組みを実現しています。
属性のスコープをタグ単位に絞ったことも重要な変更です。変更前は sgid や filename などの添付ファイル属性がグローバルに許可されていたため、任意のタグでこれらの属性が通過していました。変更後は ATTACHMENT_ATTRIBUTES が <attachment> タグにのみ適用されるため、サニタイザーの許可範囲が最小権限に近づいています。
トレードオフとして、_htmlConversions はLexicalの内部APIであり、アンダースコアプレフィックスが示すように公開インターフェースではありません。このAPIへの依存はLexicalのバージョンアップによって壊れるリスクを持ちますが、PR内では「インポート可能なタグ」を自動的に取得できることの利便性が優先されています。
まとめ
この変更は、エクステンションとサニタイザーの結合を断ち切り、エクステンション追加時の設定コストをゼロに近づけることを目的としています。allowedElements ゲッターという単一の宣言点を設けることで、許可リストの管理がエクステンション内で完結し、エクステンション開発者が意識すべき表面積が明確に定義されました。