「URLのゴミ」が実行権限に化ける:DOM-based XSSの正体と実戦的な封じ込め
現場でインシデント対応をしていると、「XSSなんて今さら」と高を括っている若手が必ずと言っていいほど躓くのが DOM-based XSS だ。サーバーサイドでバリデーションを完璧に組んでいても、クライアントサイドでJavaScriptが「信頼してはいけない入力」を「実行エンジン」に流し込んだ瞬間、防御壁は砂上の楼閣と化す。
今日は、教科書的な説明はすっ飛ばして、なぜDOM-based XSSが「見えない脅威」なのか、そしてどうやってコードレベルで息の根を止めるのかを叩き込む。
—
1. なぜDOM-based XSSは防ぎにくいのか?
反射型(Reflected)や蓄積型(Stored)のXSSがサーバーサイドのログに残るのに対し、DOM-based XSSはブラウザの中で完結する。サーバー側から見れば、単なる「静的なHTML」と「読み込まれるJS」が配信されているだけで、悪意のあるペイロードはサーバーのログにすら残らないことが多い。
攻撃者の視点:どこが「盲点」か
攻撃者は、URLのフラグメント(#以降)やlocation.searchを執拗に狙う。これらはサーバーに送信されないため、WAFやサーバー側の入力バリデーションをすり抜ける。
攻撃のPoC(概念実証):
// 脆弱なサイト: https://example.com/app.html
// 攻撃URL: https://example.com/app.html#
// サイト内のJSでこんな書き方をしていたら即死する
const fragment = decodeURIComponent(window.location.hash.substring(1));
document.getElementById(‘display’).innerHTML = fragment;
innerHTMLにURLパラメータをそのまま流し込む。これが「ソース(危険な入力源)」から「シンク(実行ポイント)」への最短ルートだ。
—
2. 「シンク」を特定し、無力化する
DOM-based XSSを撲滅する唯一の道は、「危険なシンクにデータを渡すな」に尽きる。
避けるべき「危険なシンク」たち
innerHTML,outerHTMLdocument.write()eval(),setTimeout(),setInterval()(文字列を渡す場合)location.hrefへの直接代入(javascript:スキームを含む場合)
—
3. 実務で使える「コピペ可能な」セキュア実装
「動的なコンテンツの挿入」が必要な場合、innerHTMLの代わりにtextContentやinnerTextを使うのが鉄則だ。これらはブラウザ側で自動的にエスケープされる。
安全な実装例: ユーザー入力を安全にレンダリングする
/
- セキュアなテキスト挿入関数
- @param {string} selector ターゲット要素のID
- @param {string} userInput ユーザーからの入力値
/
function safeRender(selector, userInput) {
const element = document.getElementById(selector);
if (!element) return;
// NG: element.innerHTML = userInput;
// OK: textContentを使うことで、HTMLタグをただの文字列として処理する
element.textContent = userInput;
}
// URLフラグメントを安全に扱う例
const hashValue = window.location.hash.substring(1);
safeRender(‘welcome-message’, こんにちは、${hashValue}さん!);
どうしてもHTMLとして挿入する必要がある場合は?
DOMPurifyのような堅牢なライブラリを使い、サニタイズを徹底する。自作の正規表現置換で防ごうとするのは、セキュリティのプロから見れば「自殺行為」だ。
// DOMPurifyを利用した安全なHTML挿入
//
const dirtyInput = “Hello“;
const cleanHTML = DOMPurify.sanitize(dirtyInput);
document.getElementById(‘content’).innerHTML = cleanHTML;
// 結果: Hello だけが残り、悪意あるタグは削除される
—
4. 多重防壁:CSP(Content Security Policy)でトドメを刺す
コードのミスは必ず起きる。だからこそ、多重防壁が必要だ。CSPを設定すれば、仮に脆弱なコードが紛れ込んでも、インラインスクリプトの実行を阻止できる。
NginxでのCSP設定例
サーバー側で以下のヘッダーを送信し、インラインスクリプトの実行を禁止する。
信頼できないソースからのスクリプト実行を禁止し、インラインスクリプトを無効化
add_header Content-Security-Policy “default-src ‘self’; script-src ‘self’; object-src ‘none’;”;
script-src 'self': 自分のドメインにあるJSファイルしか実行させない。'unsafe-inline'を含めないことがDOM-based XSS封じ込めの鍵だ。
—
チーフからのアドバイス
DOM-based XSSを「修正」することよりも、「脆弱な関数を使わない設計」をチームのカルチャーにすることの方が遥かに重要だ。
1. 「innerHTML」を見つけたら即座にコードレビューで指摘する。
2. 「URLパラメータは常に攻撃者のもの」という前提で実装する。
3. CSPを厳格に適用し、開発環境から「セキュリティのガードレール」を意識させる。
技術は常にアップデートされるが、脆弱性の本質は変わらない。「信用してはいけないものを、実行してはいけない場所に渡さない」。これだけ守れば、君たちの書くコードは一気に堅牢になる。次のデプロイから、まずはinnerHTMLの撲滅を始めてみてくれ。
コメント