DOM-based XSSのsink/source分類と緩和原理
クライアント側だけで起きるXSSを、データの流れから根絶できる。sourceからsinkへ抜ける経路を分類し、反射/格納型との違い、エスケープとTrusted Typesの効きどころを原理から押さえます。
- 1.DOM-based XSSはサーバーを介さず、ブラウザ内でsource(攻撃者が操れる入力)からsink(コードやマークアップを生む危険なAPI)へデータが流れて成立する。location.hashなどフラグメントが代表的source。
- 2.反射/格納型がサーバーの出力に混入するのに対し、DOM型はレスポンス本文に痕跡が出ず、ペイロードがサーバーに届かないこともある。だからWAFやサーバー側エスケープでは検知も防御もしにくい。
- 3.緩和の核は危険なsinkを使わず安全なsinkへ置換すること。textContentやsetAttributeへ寄せ、避けられないsinkはコンテキスト別エスケープかDOMPurifyで浄化し、Trusted Typesでsinkを型レベルに封じる。
DOM-based XSS は、サーバーが返した HTML には脆弱性が無いのに、ページ上で動く JavaScript が攻撃者の制御下にあるデータを危険な API へ渡してしまうことで成立する XSS です。攻撃のすべてがブラウザ内で完結するため、サーバーログにもレスポンス本文にも痕跡が残りにくく、従来のサーバー側対策をすり抜けます。ここでは「source から sink へのデータフロー」というモデルでその原理と緩和を整理します。
source と sink の定義
DOM-based XSS は、taint analysis(汚染解析)と同じ枠組みで捉えると正確に理解できます。登場人物は3つです。
| 用語 | 意味 | 代表例 |
|---|---|---|
| source | 攻撃者が値を操作できる入力点 | location.hash / location.search / document.referrer / window.name / postMessage の data |
| sink | 文字列をコード・マークアップ・URLとして解釈する危険なAPI | innerHTML / outerHTML / document.write / eval / setTimeout(文字列) / location 代入 |
| sanitizer | sourceとsinkの間で値を無害化する処理 | textContentへの置換 / エスケープ / DOMPurify / Trusted Types |
脆弱性は「source の値が、無害化されないまま sink に到達する経路(taint flow)が存在するとき」に限って生じます。逆に言えば、source 単体・sink 単体は危険ではありません。location.hash を読んでも textContent に入れるだけなら安全ですし、innerHTML も定数を入れる限り無害です。問題は両者が一本の流れでつながった瞬間に起きます。
// 典型的なDOM-based XSS。hash(source)が無害化されずinnerHTML(sink)へ届く
const id = location.hash.slice(1); // source: #<img src=x onerror=alert(1)>
document.querySelector('#tab').innerHTML = id; // sink: 文字列をHTMLとして解釈
innerHTML は代入された文字列をパースして DOM 部分木を構築します。このとき <script> 要素は仕様上その場では実行されませんが、<img onerror=...> や <svg onload=...> のようなイベントハンドラ属性は解釈・登録され、画像読み込み失敗などをトリガに任意コードが走ります。<script> が動かないからといって innerHTML が安全になるわけではない、というのが要点です。
反射型・格納型との本質的な違い
XSS は注入されたペイロードがどこで HTML に混入するかで3分類されます。DOM 型だけ混入箇所がサーバーではなくクライアントである点が決定的に異なります。
| 分類 | ペイロードがHTMLになる場所 | サーバーに届くか | レスポンス本文に出るか |
|---|---|---|---|
| 反射型 (Reflected) | サーバーが当該リクエストの値を即座に埋めて返す | 届く | 出る |
| 格納型 (Stored) | サーバーが保存した値を後続レスポンスに埋める | 届く(保存される) | 出る |
| DOM型 (DOM-based) | クライアントのJSがsinkで動的に生成する | 届かないことがある | 出ない |
最も実務的に効くのが「届くか」「出るか」の2列です。フラグメント識別子(URL の # 以降)はブラウザがサーバーへ送信しないため、location.hash を source とする攻撃ではペイロードがサーバーに一切到達しません。結果として、サーバー側のエスケープも、WAF のシグネチャ検査も、サーバーログ監視も無力になります。検知と防御の責務がまるごとクライアントへ移るのが DOM 型の本質です。
反射型・格納型は「サーバーがテンプレートに値を差し込む箇所」のエスケープ漏れが原因です。DOM 型は「ブラウザ上の JS が sink へ値を渡す箇所」が原因で、サーバー出力は正しくても発生します。1つのページが反射型と DOM 型を同時に抱えることもあり、サーバー側テンプレートとクライアント側 JS の両方を独立に監査する必要があります。
sink のコンテキスト分類
緩和の方針は sink が値を「何として」解釈するかで決まります。コンテキストを取り違えたエスケープは無力です。
| sinkコンテキスト | 代表API | 必要な無害化 |
|---|---|---|
| HTMLパース | innerHTML / outerHTML / document.write / insertAdjacentHTML | HTMLサニタイズ(DOMPurify等)かtextContentへ退避 |
| JS実行 | eval / new Function / setTimeout(文字列) / setInterval(文字列) | 文字列を渡さない。関数参照を渡す |
| URLナビゲーション | location代入 / a.href / window.open | javascript: スキームを拒否、許可スキームのみ通す |
| 属性値 | setAttribute('srcdoc'/'on*'/...) | イベントハンドラ・srcdocは値で設定しない |
URL コンテキストは見落とされがちです。a.href = userInput で userInput が javascript:alert(1) だと、クリック時にスクリプトとして評価されます。HTML エスケープは効かず、スキームの許可リスト判定(https: と http: と相対パスのみ通す等)が要ります。JS コンテキストの setTimeout も、第1引数に文字列を渡せばその文字列が eval 相当で実行されるため、必ず関数参照を渡します。
緩和の原理 — 危険な sink を断つ
DOM-based XSS の根本対策は「source を消す」ことではなく「source の値が危険な sink へ生で届かないようにする」ことです。優先順位の高い順に押さえます。
// 1. 安全なsinkへ置換する(最優先)。textContentはHTMLを解釈しない
el.textContent = location.hash.slice(1); // <img ...> はただの文字列になる
// 2. 構造が要るならDOM APIで組む。文字列連結でHTMLを作らない
const a = document.createElement('a');
a.textContent = label;
if (/^https?:/.test(url)) a.href = url; // スキームを検証してから設定
// 3. HTMLを通したい場合はサニタイザで浄化してからsinkへ
el.innerHTML = DOMPurify.sanitize(richText); // 危険な要素・属性を除去
第一選択は安全な sink への置換です。innerHTML を textContent に変えるだけで、文字列は HTML としてパースされず、いかなるタグもイベント属性も無効化されます。表示にマークアップが要らないなら、これだけで当該経路は閉じます。ノード操作のコストや live/static の挙動は DOMツリーの内部表現とノード操作のコスト も参照してください。
リッチテキストなど HTML 自体を通す必要がある場合に限り、サニタイザ(DOMPurify 等)で許可リストベースに要素・属性を絞ってから sink へ渡します。手書きの正規表現で <script> を消すだけの「ブラックリスト」は、<img onerror> や属性内の改行、HTML パーサの寛容な誤り訂正によって容易にすり抜けられるため、原理的に不完全です。
Trusted Types — sink を型で封じる
エスケープやサニタイズは「呼び出しを忘れた1か所」で破綻します。Trusted Types は、危険な sink が生の文字列を受け付けないようにブラウザ側で強制することで、この「うっかり」をプラットフォームレベルで排除する仕組みです。
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types myPolicy
// このポリシーを通った値だけがTrustedHTMLになる
const policy = trustedTypes.createPolicy('myPolicy', {
createHTML: (s) => DOMPurify.sanitize(s), // 浄化を一元化
});
el.innerHTML = policy.createHTML(richText); // 生文字列を直接代入すると例外
require-trusted-types-for 'script' を CSP で宣言すると、innerHTML などの「injection sink」へ普通の文字列を代入した時点で TypeError になります。通せるのは TrustedHTML/TrustedScript/TrustedScriptURL という専用型のオブジェクトだけで、それらは登録したポリシー関数を経由しないと生成できません。結果として、アプリ中に散らばった無数の sink 呼び出しを、ポリシー定義という一点の監査対象へ集約できます。サニタイズ漏れは「忘れ得るコード」から「定義しないと動かない構造」へ変わるわけです。
Trusted Types は CSP のディレクティブとして配信され、nonce ベースの strict CSP と相補的に働きます。CSP がスクリプトの実行を許可リストで縛るのに対し、Trusted Types は危険な sink への到達を型で縛ります。両者の評価原理は Content Security Policyの内部動作と回避耐性 を参照してください。導入は Report-Only モードから始め、違反レポートで既存の sink 利用箇所を洗い出してからポリシー化するのが定石です。
postMessage と信頼境界
postMessage の受信ハンドラは見落とされやすい source です。受信した event.data を検証せず sink へ渡すと、別オリジンの窓から任意のペイロードを送り込まれます。対策は受信側で必ず event.origin を許可リストと照合し、データ構造も検証することです。
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted.example') return; // origin検証は必須
if (typeof event.data?.html !== 'string') return; // 構造も検証
el.textContent = event.data.html; // 安全なsinkへ
});
event.origin はブラウザが設定する改竄不能な値で、送信元の本当のオリジンを示します。ここを検査しないと、オリジンをまたぐ信頼境界が崩れます。オリジンとサイトの違い、窓どうしの信頼境界の原理は 同一オリジンポリシーとサイト分離の信頼境界 で整理しています。
任意の JS が走れば、攻撃者は同一オリジンの document.cookie(HttpOnly 無しの場合)や localStorage 上のトークンを読み出して外部へ送れます。だから DOM 型対策は、Cookie とセッション の HttpOnly/SameSite と重ねて初めて意味を持ちます。sink を断つクライアント側対策と、奪われても被害を抑えるトークン保護は、どちらが欠けても穴になります。
Web/フロントエンド Article
DOM-based XSSのsink/source分類と緩和原理を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
セキュリティ
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
反射/格納型がサーバーの出力に混入するのに対し、DOM型はレスポンス本文に痕跡が出ず、ペイロードがサーバーに届かないこともある。だからWAFやサーバー側エスケープでは検知も防御もしにくい。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「セキュリティ / Web」に近いか確認する。
- 強みである「DOM-based XSSはサーバーを介さず、ブラウザ内でsource(攻撃者が操れる入力)からsink(コードやマークアップを生む危険なAPI)へデータが流れて成立する。location.hashなどフラグメントが代表的source。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。