技術 約6分で読めます

Firefox 148が搭載したSanitizer API、setHTMLでinnerHTMLを安全に置き換える

XSSがWeb脆弱性のトップ3(CWE-79)にランクインし続けてほぼ10年になる。Mozillaは2009年にContent-Security-Policy(CSP)を提唱し、XSSへの根本的な対策を模索してきたが、十分な普及には至らなかった。CSPは既存サイトに大きなアーキテクチャ変更を強いるためだ。継続的な改善を重ねてきたにもかかわらず、長い尾を持つWebを守るには至らなかった。

2026年2月24日リリースのFirefox 148でその状況が変わった。WICGでインキュベーション中のSanitizer APIをFirefoxが初めて「安全版」として出荷した。ブラウザに組み込まれたサニタイザーを通じてHTMLを挿入する setHTML() メソッドにより、開発者は最小限の変更でXSSを防ぐ手段を手に入れた。

innerHTML の何が問題か

innerHTML はHTMLを直接DOMに挿入する。ユーザー入力や外部データを無検証でそのまま渡すと、攻撃者が仕込んだスクリプトが実行される。

// 危険な使い方
element.innerHTML = userInput;

これを防ぐためにサードパーティライブラリ(DOMPurify等)でサニタイズするのが一般的な対策だが、ライブラリ側の実装ミスやバイパスが発見されてきた歴史がある。ブラウザ側にそのロジックが入れば、開発者が個別にサニタイズを実装・管理する必要がなくなる。

setHTML()の基本動作

Mozilla Hacks公式ブログに掲載された例:

document.body.setHTML(`<h1>Hello my name is <img src="x" onclick="alert('XSS')">`);

この呼び出しは <img> 要素とその onclick 属性を除去し、以下の安全なHTMLだけを残す。

<h1>Hello my name is</h1>

デフォルトのサニタイザーは想像より厳格だ。スクリプト実行につながる要素(<script><iframe><embed><object>、SVGの<use>)とイベントハンドラ属性(onclickonerror 等)、javascript: URLを除去するのはもちろん、<style><link><img><video><button><form><input><textarea><template>、カスタム要素、data-*属性、aria-*属性、インラインスタイルもデフォルトで除去される。通常のテキストコンテンツ(見出し・段落・リンク等)だけがそのまま維持される。

カスタム設定

デフォルト設定が厳しすぎる、あるいは緩すぎる場合は第二引数でサニタイザーを設定できる。

const sanitizer = new Sanitizer({
  allowElements: ['p', 'b', 'i', 'a', 'ul', 'li'],
  allowAttributes: {
    'a': ['href']
  }
});
element.setHTML(userInput, { sanitizer });

主な設定オプション:

  • allowElements — 許可する要素のホワイトリスト
  • allowAttributes — 各要素で許可する属性
  • blockElements — 要素タグを取り除き子要素は維持する(<div> を展開するなど)
  • dropElements — 要素と子要素を丸ごと削除する

リッチテキストエディタで <table><colgroup> を許可したい場合、あるいはコメント欄でコード表示のために <pre><code> を明示的に許可したい場合など、用途に応じた調整が必要になる。実装前に Sanitizer API Playground で動作を確認できる。

なお setHTML() では、たとえ設定で <script> を許可しても強制的に除去される。XSSベースライン(スクリプト実行に直結する要素・属性の除去)は設定で上書きできない仕様だ。

setHTMLUnsafe()

setHTML() と対になるメソッドとして setHTMLUnsafe() がある。こちらはXSSベースラインの強制適用を行わない。

// Declarative Shadow DOMの挿入に必要
element.setHTMLUnsafe(`
  <div>
    <template shadowrootmode="open">
      <p>Shadow DOM content</p>
    </template>
  </div>
`);

setHTML() では <template> が除去されるため、Declarative Shadow DOMを使う場合は setHTMLUnsafe() が必要になる。名前に「Unsafe」とあるが、Trusted Typesと組み合わせればTrustedHTMLオブジェクト経由でのみHTMLを受け付けるため安全に運用できる。

setHTMLUnsafe()setHTML() より先に標準化が進んでおり、Chrome 124(2024年4月)とSafari 17.4(2024年3月)で出荷済み。Firefox 148で追加され、2025年9月にBaseline 2025 Newly availableに到達している。

メソッドXSSベースライン強制Declarative Shadow DOMTrusted Typesのsink
setHTML()常に適用非対応No(文字列をそのまま渡せる)
setHTMLUnsafe()適用しない対応Yes(TrustedHTMLが必要)

Trusted Typesとの連携

より強固な保護にはTrusted Typesとの組み合わせが有効だ。Trusted Typesは innerHTML 等の危険なDOM操作を型システムで制限し、TrustedHTML 型の値だけを受け付けるよう強制する仕組みだ。

setHTML() を導入済みのサイトであれば、Trusted Typesのstrict policyを有効にしやすくなる。setHTML() を明示的に許可し他の危険な代入をすべてブロックするポリシーを設定することで、将来的なXSSの差し込みを防ぎやすくなる。Firefox 148はSanitizer APIとTrusted Typesの両方をサポートしている。

DOMPurifyはなぜ構造的に不完全か

DOMPurifyのようなライブラリは「HTML文字列をパース → サニタイズ → シリアライズ → innerHTML で再パース」という処理を行う。この二重パースの過程でHTMLパーサの挙動差(mutation)を悪用するmXSS(Mutation XSS)攻撃が知られており、実際にDOMPurifyには複数のバイパスが発見されている:

  • DOMPurify 2.0.0: MathML名前空間の混同によるバイパス
  • DOMPurify 2.0.17: 別の名前空間混同によるバイパス

setHTML() はブラウザの実際のHTMLパーサ上で直接DOM treeをフィルタリングするため、パーサの差異が存在しない。mXSS攻撃が構造的に不可能になる点が、ライブラリベースのサニタイズとの根本的な違いだ。

仕様の波乱の歴史

Sanitizer APIの標準化は一筋縄ではいかなかった。

  • 2020年: WICGがブラウザネイティブのHTML sanitization機構の開発を開始
  • 2022年: Chrome 105が当時のドラフト仕様に基づく初期版を出荷(Sanitizer コンストラクタ + .sanitize() / .sanitizeFor() メソッド)
  • 2023年: 仕様が大幅に再設計され、Chrome 119で旧APIが非推奨・削除。古い実装が定着するのを防ぐための措置だった
  • 2024年: setHTMLUnsafe() / parseHTMLUnsafe() がChrome 124、Safari 17.4で先行出荷
  • 2026年2月: Firefox 148とChrome 146 betaで「安全版」の setHTML() + Sanitizer 設定オブジェクトが初めて出荷

仕様はまだWICG Draft Community Group Reportの段階で、最終的にWHATWG HTMLに統合する計画がある。W3C標準にはなっていない。

ブラウザサポート状況

setHTML() + Sanitizer(安全版)のサポート:

ブラウザバージョン状態
Firefox148(2026/2/24)出荷済み
Chrome146 beta(2026/2/11)beta。Stable予定は2026年3月上旬
Edge146相当Chrome 146 stableと同時期に出荷見込み
Safari未実装WebKit standards-positionsで肯定的だが時期未定

Chrome 146のstableリリースで主要ブラウザ2つが揃う。Safariが追随するまではフィーチャーディテクションでのフォールバックが現実的だ:

function safeInsertHTML(element, html) {
  if ("setHTML" in Element.prototype) {
    element.setHTML(html);
  } else {
    element.innerHTML = DOMPurify.sanitize(html);
  }
}

採用のパス

既存コードからの移行は比較的単純だ。多くの箇所で innerHTML の代入を setHTML() に書き換えるだけで済む。

// Before
element.innerHTML = sanitizedHtml;

// After
element.setHTML(html);  // sanitizationはブラウザが担当

CSPと違い、サイト全体のアーキテクチャ変更は不要で、危険な代入箇所を順次置き換えていける。

ただし setHTML() はブラウザAPIのため、サーバーサイド(Node.js等)では使えない。サーバーでのサニタイズにはDOMPurify + jsdomが引き続き必要になる。また setHTML() はDOM操作のみでサニタイズ済み文字列を返さないため、DOMPurifyの sanitize() が返す文字列が必要な用途では代替にならない。

長期的にはDOMPurifyを置き換える存在だが、ブラウザサポートが揃うまではフォールバック付きでの段階的導入が現実的なパスになる。