Trusted TypesによるDOM-based XSSの構造的防止
サニタイズ漏れを「うっかり」ではなく「構造的に不可能」へ変えられる。危険なsinkへの文字列代入を型でブロックし、ポリシー一点へ監査を集約する仕組みと段階導入を原理から押さえます。
- 1.Trusted Typesは innerHTML 等の injection sink に生文字列を渡した時点で TypeError にし、TrustedHTML/TrustedScript/TrustedScriptURL という専用型のオブジェクトだけを通す。型を作れるのは登録したポリシー関数だけ。
- 2.CSP の require-trusted-types-for 'script' で強制し、trusted-types ディレクティブで使えるポリシー名を制限する。default ポリシーを定義すると、文字列が暗黙に渡された箇所も createHTML 等を経由させられる。
- 3.段階導入は Report-Only で違反箇所を全列挙し、サニタイズをポリシー関数へ一元化してから enforce へ切り替える。サニタイズ漏れが「忘れ得るコード」から「定義しないと動かない構造」へ変わるのが本質。
エスケープやサニタイズによる DOM-based XSS 対策は、突き詰めると「危険な API を呼ぶ前に必ず無害化を挟む」という規律に依存します。問題は、その規律が1か所でも破られれば破綻することです。Trusted Types は、危険なシンクが生の文字列を物理的に受け付けないようブラウザ側で強制し、この「うっかり」をプラットフォームレベルで排除します。ここでは型による遮断の原理と、既存コードへの段階導入を整理します。
何を型で禁止するのか
Trusted Types が守るのは injection sink と呼ばれる一群の DOM API です。これらは渡された文字列をコード・マークアップ・スクリプト URL として解釈する性質を持ちます。
| 要求される型 | 対象シンクの例 | 解釈のされ方 |
|---|---|---|
| TrustedHTML | innerHTML / outerHTML / insertAdjacentHTML / iframe.srcdoc / document.write | 文字列をHTMLとしてパースしDOMを構築 |
| TrustedScript | eval / new Function / script.text / script.textContent | 文字列をJSソースとして実行 |
| TrustedScriptURL | script.src / HTMLScriptElement の URL 属性 / Worker の引数 | URLを実行可能スクリプトとして読み込み |
require-trusted-types-for 'script' が有効なドキュメントでは、これらシンクへ普通の文字列を代入した瞬間に TypeError が投げられ、代入自体が起きません。通せるのは対応する Trusted 型のオブジェクトだけです。重要なのは、これらの型がコンストラクタを公開していない点です。new TrustedHTML() はできず、唯一の生成経路は登録済みポリシー関数の呼び出しに限られます。型システムが「無害化を経由したことの証明書」として機能する、というのが設計の核心です。
ポリシー — 型の唯一の生成口
ポリシーは trustedTypes.createPolicy で作る、変換関数の名前付きの束です。createHTML/createScript/createScriptURL の3つを必要なぶんだけ実装します。
const policy = trustedTypes.createPolicy('app-html', {
createHTML: (input) => DOMPurify.sanitize(input), // 浄化してから型を発行
createScriptURL: (url) => {
const u = new URL(url, location.origin);
if (u.origin !== location.origin) throw new TypeError('cross-origin script');
return u.href; // 同一オリジンのみ許可
},
});
el.innerHTML = policy.createHTML(richText); // TrustedHTML が返り、代入が通る
el.innerHTML = richText; // 生文字列 → TypeError
policy.createHTML(s) は内部で変換関数を呼び、その戻り値の文字列を TrustedHTML でラップして返します。つまり変換関数自体はただの文字列を返せばよく、ラップはブラウザが行います。ここで DOMPurify.sanitize のような浄化を呼ぶことで、「型を得るには必ず浄化を通る」という不変条件が成立します。変換関数が浄化をサボれば穴になりますが、そのコードはポリシー定義というただ一点に集約されるため、アプリ全体に散らばった無数のシンク呼び出しではなく、この関数だけを監査すればよくなります。これが Trusted Types の費用対効果の源泉です。
TrustedHTML は「内容が安全」を保証しません。保証するのは「登録ポリシーを経由した」という来歴だけです。createHTML: (s) => s という恒等ポリシーを書けば、無害化ゼロの TrustedHTML も作れてしまいます。Trusted Types の価値は「危険なシンクへ到達する経路を、監査可能な少数のポリシーへ強制的に絞り込む」ことであり、各ポリシーが実際に安全な変換を実装する責任は依然として開発者にあります。
CSP による配信とポリシー名の制限
Trusted Types は独立した API ではなく、CSP のディレクティブとして配信・強制されます。2つのディレクティブが役割を分担します。
Content-Security-Policy:
require-trusted-types-for 'script';
trusted-types app-html app-url 'allow-duplicates'
require-trusted-types-for 'script' がシンクの強制スイッチで、これが無ければ Trusted Types API は使えても遮断は起きません。trusted-types ディレクティブは使ってよいポリシー名のホワイトリストで、ここに無い名前で createPolicy を呼ぶと例外になります。これにより、注入された攻撃コードが勝手に createPolicy('evil', { createHTML: s => s }) で恒等ポリシーを作って遮断を骨抜きにする、という攻撃を防ぎます。同名ポリシーの重複登録は既定で禁止(2回目で例外)で、許す場合のみ 'allow-duplicates' を付けます。trusted-types 'none' と書けば、いかなるポリシー生成も禁止できます。
評価の土台となる CSP のディレクティブ照合とフォールバック規則は Content Security Policyの内部動作と回避耐性 を参照してください。Trusted Types はスクリプトの実行を縛る script-src とは独立に、危険なシンクへの到達を縛る相補的な層です。
default ポリシーと暗黙の文字列代入
実コードでは、自分が直接書いていない箇所——サードパーティ製ライブラリやフレームワーク内部——がシンクへ文字列を渡すことが避けられません。これらをすべて書き換えるのは非現実的です。そこで default という特別な名前のポリシーが用意されています。
trustedTypes.createPolicy('default', {
createHTML: (input, type, sink) => {
// type には 'TrustedHTML'、sink には 'Element innerHTML' などが渡る
return DOMPurify.sanitize(input);
},
});
default ポリシーを定義すると、Trusted 型が要求される箇所に生文字列が渡されたとき、例外を投げる代わりにブラウザが自動でこの default.createHTML を呼び、その結果を使います。コールバックには入力文字列のほか、要求される型名('TrustedHTML' など)とシンク名('Element innerHTML' など)が渡るので、どの呼び出し元かを判別してログや個別処理ができます。これにより、改変できない既存コードを一括で救済できます。
default ポリシーは強力ですが、すべての生文字列を吸い込むため、ここを緩く書くと「型で縛った」意味が薄れます。アプリ本体のコードは名前付きポリシー(policy.createHTML(...))で明示的に通すのが基本で、default は自分で書き換えられないライブラリの救済や移行期の互換層に限定するのが定石です。default の変換関数で例外を投げれば、その経路だけを「強制ブロック」へ戻すこともできます。
段階導入 — Report-Only から enforce へ
いきなり強制すると、生文字列をシンクへ渡している既存箇所がすべて TypeError で止まり、ページが壊れます。そこで CSP と同じく Report-Only から始めるのが定石です。
| 段階 | ヘッダ | 起きること |
|---|---|---|
| 1. 観測 | Content-Security-Policy-Report-Only: require-trusted-types-for 'script' | 違反箇所をブロックせず report-to へ全列挙 |
| 2. 集約 | (コード側)名前付きポリシーへ浄化を一元化 | シンク呼び出しを policy.createHTML 経由へ書き換え |
| 3. 救済 | default ポリシーを定義 | 書き換え不能なライブラリの文字列代入を自動浄化 |
| 4. 強制 | Content-Security-Policy: require-trusted-types-for 'script' | 残った生文字列代入を TypeError で遮断 |
Report-Only モードでは、シンクへの生文字列代入はそのまま実行されつつ違反レポートだけが送られます。report-to で指定したエンドポイントに、どのシンクでどのファイル・行から違反が起きたかが届くため、アプリ中の Trusted Types 非対応箇所を網羅的に棚卸しできます。レポートを潰し切ってから enforce ヘッダへ切り替えれば、ユーザー影響なしに移行できます。
// 違反レポートの受信(Reporting API 経由、report-to で配信先を宣言)
// 各レポートに sink 名・blockedURL・sourceFile・lineNumber が含まれる
移行が終わったら trusted-types ディレクティブを実際に使うポリシー名だけに限定します。require-trusted-types-for 'script' だけで trusted-types を省くと、攻撃者が任意名のポリシーを作れる余地が残ります。シンクの強制(require)とポリシー生成の制限(trusted-types)は両方書いて初めて、注入経路と回避経路の両面を塞げます。DOM-based XSS の sink/source モデル全体での位置づけは DOM-based XSSのsink/source分類と緩和原理 を参照してください。
限界と多層防御での位置づけ
Trusted Types はブラウザ実装に依存します。未対応ブラウザでは API も強制も無視されるため、Trusted Types を「唯一の対策」にはできません。あくまで、対応ブラウザでサニタイズ漏れを構造的に不可能にする緩和層です。
Trusted Types が遮断するのは「危険なシンクへ無害化されていない文字列が届くこと」です。万一どこかで XSS が成立すれば、攻撃者は同一オリジンの Cookie やトークンを奪えます。だから Trusted Types は、CSP の strict 化、Cookie とセッション の HttpOnly/SameSite、CSRFの発生機構と防御 の対策と重ねて初めて意味を持ちます。型による sink 遮断・実行制限の CSP・奪われても被害を抑えるトークン保護は、どれが欠けても穴になります。
Web/フロントエンド Article
Trusted TypesによるDOM-based XSSの構造的防止を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
セキュリティ
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
CSP の require-trusted-types-for 'script' で強制し、trusted-types ディレクティブで使えるポリシー名を制限する。default ポリシーを定義すると、文字列が暗黙に渡された箇所も createHTML 等を経由させられる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「セキュリティ / Web」に近いか確認する。
- 強みである「Trusted Typesは innerHTML 等の injection sink に生文字列を渡した時点で TypeError にし、TrustedHTML/TrustedScript/TrustedScriptURL という専用型のオブジェクトだけを通す。型を作れるのは登録したポリシー関数だけ。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。