「innerHTMLを使えば一瞬で終わる」という誘惑を捨てろ:XSSからアプリを守るDOM操作の鉄則
現場でコードレビューをしていると、今でもたまに見かけるんだ。「element.innerHTML = userInput;」という一行が。書いた本人に聞くと、「手っ取り早いから」「動くから」という答えが返ってくる。だが、その一行が君のアプリを、そしてユーザーのセッションを、攻撃者に明け渡すゲートウェイになっていることに気づいていない。
今日は、XSS(クロスサイトスクリプティング)の根源である「危険なシンク(Sink)」を叩き潰し、現代のWeb開発で避けては通れない「セキュアなDOM操作」の本質について、綺麗事抜きで語る。
—
1. なぜ innerHTML は「毒」なのか
XSSのメカニズムはシンプルだ。攻撃者は、アプリケーションが意図しないスクリプトを、ブラウザが「正当なコード」だと解釈するように仕向ける。
特に innerHTML や document.write は、文字列を「HTML」として解釈し、レンダリングする。そこに悪意ある のようなペイロードが紛れ込めば、ブラウザは何の疑いもなくそのスクリプトを実行してしまう。
攻撃者視点:DOMベースXSSのPoC
もし君がチャットアプリを作っていて、ユーザーのプロフィール名をそのまま innerHTML で反映していたら、攻撃者はこう動く。
// 攻撃者がプロフィール名に設定する文字列
const maliciousInput = ““;
// これが実行されると、あなたのアプリは自らユーザーのCookieを攻撃者に送信する
document.getElementById(‘profile-name’).innerHTML = maliciousInput;
これだけで、セッションハイジャックの完成だ。WAFでフィルタリングしていても、DOM内で動的に生成される悪意あるコードまでは防げないことが多い。だからこそ、フロントエンド側での「シンクの選定」が最終防衛線になるんだ。
—
2. 安全なDOM操作:代替APIへの置き換え
「文字列を直接HTMLとして流し込む」という設計思想そのものを捨てる必要がある。ブラウザには、安全にテキストを扱うための強力なAPIが用意されている。
textContent vs innerText
textContent: 要素内のすべてのテキスト(隠し要素も含む)を扱う。パースが発生しないため、最も高速で安全。innerText: CSSの表示状態を反映する。基本的にはtextContentで事足りる。
セキュアな実装パターン(JavaScript)
// ❌ 絶対にやってはいけない
// element.innerHTML = userInput;
// ✅ 推奨:textContentを使う
const userElement = document.getElementById(‘user-display’);
const userInput = getUserInput(); // 外部からの入力
// これなら、たとえuserInputにHTMLタグが含まれていても
// ただの「文字列」としてブラウザに表示されるだけで、実行はされない
userElement.textContent = userInput;
—
3. どうしてもHTMLをレンダリングしたい場合は?
「いや、どうしてもユーザーが書いた太字やリンクを許可したいんだ」という場面もあるだろう。その場合でも、innerHTML は禁じ手だ。DOMPurify という最強のサニタイズライブラリを使え。
DOMPurifyを使った防御のベストプラクティス
// npm install dompurify
import DOMPurify from ‘dompurify’;
const dirtyInput = “
こんにちは
“;
// 攻撃的なタグや属性だけを完璧に除去し、安全なHTMLだけを返す
const cleanHTML = DOMPurify.sanitize(dirtyInput);
// 安全になったHTMLだけを挿入する
document.getElementById(‘content’).innerHTML = cleanHTML;
—
4. インフラ・設定レベルでの多層防御(Defense in Depth)
アプリケーションコードの修正はもちろん重要だが、万が一の漏れを防ぐために、サーバー側でも「ブラウザにスクリプトを許さない」という強制力を持たせる必要がある。
Content Security Policy (CSP) の設定
Nginxのレスポンスヘッダーに以下を追加してくれ。これにより、仮にXSSが埋め込まれても、外部へのデータ送信や意図しないスクリプトの実行をブラウザ側でブロックできる。
Nginx設定例
add_header Content-Security-Policy “default-src ‘self’; script-src ‘self’; object-src ‘none’; frame-ancestors ‘none’;”;
default-src 'self': 基本的に自分のドメインからのリソースしか読み込まない。script-src 'self': インラインスクリプトを禁止し、外部からの不正なJS実行を防ぐ。
—
最後に:プロフェッショナルであるために
セキュリティは「チェックリストを埋める作業」じゃない。「自分のコードがどう悪用されるか」を想像するクリエイティブな作業だ。
innerHTML を使いたくなったら、一度立ち止まって考えてほしい。「自分はこの入力値を信用しているか?」「なぜブラウザにHTMLとして解釈させる必要があるのか?」と。
コードは、常に性悪説に基づいて書くべきだ。それが、君が守るべきユーザーと、君自身のエンジニアとしてのキャリアを守る唯一の方法だからな。
現場からは以上だ。また何かあればいつでも聞きに来てくれ。
コメント