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>)とイベントハンドラ属性(onclick、onerror 等)、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 DOM | Trusted 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(安全版)のサポート:
| ブラウザ | バージョン | 状態 |
|---|---|---|
| Firefox | 148(2026/2/24) | 出荷済み |
| Chrome | 146 beta(2026/2/11) | beta。Stable予定は2026年3月上旬 |
| Edge | 146相当 | 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を置き換える存在だが、ブラウザサポートが揃うまではフォールバック付きでの段階的導入が現実的なパスになる。