Rails は自動でエスケープしているのに、なぜ XSS はなくならないのか?
<%= @comment.body %> と書くだけで、Rails は自動的に HTML エスケープしてくれる。React も {} で値を出すだけなら自動でエスケープされる。それなのに、なぜ XSS(クロスサイトスクリプティング)は毎年 OWASP の脆弱性ランキングの常連であり続けるのか。原因は「エスケープを書き忘れる」ことだけではない。エスケープには、埋め込み先の文脈によって正しいやり方が変わるという、見落とされがちなルールがある。
- XSS は攻撃者の JS を被害者のブラウザに、被害者自身の権限(cookie・セッション)で実行させる攻撃で、原理は「入力検証の甘さ」ではなく「出力時に文脈に応じた変換をしていないこと」にある。
- HTML 本文用のエンティティエンコードは、HTML 属性・JS 文字列・URL という別の文脈に埋め込むときはそのまま安全にならない。文脈ごとに正しいエンコード規則が違う。
- 出力エスケープが漏れても被害を止める最後の壁が CSP(Content-Security-Policy)で、
script-srcを許可リスト化すれば注入されたコード自体をブラウザ側で実行拒否できる。
XSS は「誰の権限で」何が起きる攻撃か
ブラウザには同一オリジンポリシーという原則があり、あるサイトの JavaScript は別サイトの cookie や DOM に触れない。XSS はこの境界を迂回する攻撃ではなく、悪意ある JS を脆弱なサイト自身のコードとして注入し、境界の内側から実行させる攻撃だ。だから実行された JS は、被害者がそのサイトにログインしているならセッション cookie の読み出しや API 呼び出しを、被害者本人の権限でできてしまう。
攻撃経路は大きく 3 種類ある。URL パラメータなどをそのまま応答に埋め込む Reflected XSS、コメントや投稿として DB に保存された悪意ある文字列が他ユーザーの画面に表示される Stored XSS、そしてサーバーを経由せずクライアント側の JS が innerHTML 等に外部由来の文字列をそのまま渡してしまう DOM-based XSS だ。
┌────────────────┐
│ 攻撃者 │
└───────┬────────┘
│ ① 罠リンクを送る
▼
┌────────────────┐
│ 被害者 │
└───────┬────────┘
│ ② クリックしてアクセス
▼
┌────────────────┐
│ 脆弱なサイト │
└───────┬────────┘
│ ③ 入力を未エスケープで
│ HTML に埋め込み応答
▼
┌──────────────────────────┐
│ 被害者のブラウザ上で │
│ 攻撃者の JS が実行される │
│ (session cookie 送信等) │
└──────────────────────────┘
Rails の自動エスケープはどこまでを守るか
Rails は ERB テンプレートの <%= %> をデフォルトで HTML エンティティエンコードする。& < > " ' をそれぞれ & < のような実体参照に変換し、ActiveSupport::SafeBuffer というクラスで「この文字列はすでにエスケープ済みか」を追跡する仕組みだ。これは HTML 本文(タグとタグの間にテキストとして出す)という 1 つの文脈に対する変換にすぎない。
raw() や html_safe、<%== %> はこの安全装置を意図的に外すための API で、ユーザーが書いた HTML をリッチテキストとしてそのまま表示したい場合など、正当な用途もある。ただし対象がユーザー入力そのものだと、挙動は一変する。
<%# デフォルト: 自動エスケープされる(安全) %>
<%= @comment.body %>
<%# コメントに "<script>alert(1)</script>" が入っていても
画面には文字列としてそのまま表示されるだけで実行されない %>
<%# raw / html_safe: 安全装置を外す(ユーザー入力では危険) %>
<%= raw @comment.body %>
<%# 上と同じ入力が、そのまま <script> タグとして解釈・実行される %>
つまり Rails の自動エスケープは「デフォルトの出力先が HTML 本文である限り」有効な安全装置であり、コードのどこかで raw や html_safe が呼ばれた瞬間、あるいは出力先が HTML 本文以外の文脈に変わった瞬間に前提が崩れる。
「一度エスケープすれば安全」という誤解
HTML 本文用のエンティティエンコードは、他の文脈ではそのまま通用しない。HTML 属性値に差し込む場合は、値を必ずクォートで囲んだ上でクォート文字自体もエンコードしないと、属性の外側に脱出されて新しい属性(onerror=... 等)を差し込まれる。<script> 要素の中身のような JavaScript 文字列の文脈では話がさらに違う。ブラウザは <script> の中身を HTML の実体参照としてデコードしないため、HTML 用のエンティティエンコードをそこに使っても文字列の脱出(クォートを閉じて任意の JS を書き足す攻撃)を防げるとは限らない。ここでは JS の文字列エスケープ(\xHH 形式など)が必要になる。URL の一部に値を差し込む場合も同様で、パーセントエンコードするだけでは javascript: スキームそのものを止められない。
これは初学者が素朴に持ちがちな前提を裏切る。「サーバー側で一度エスケープしておけば、あとはどこに出力しても安全」ではなく、「どの文脈に出力するかによって、必要なエスケープの規則そのものが違う」というのが実態だ。
出力エスケープは、一度かければどこに埋め込んでも安全になる共通処理ではなく、海外旅行の変換プラグに近い。日本の電化製品をヨーロッパのコンセントに挿すには A タイプ用ではなく C タイプ用のアダプタが必要で、同じ「電気を通す」目的でも差し込み先の形状が違えば必要な部品は別物になる。C タイプのプラグを A タイプの穴に無理やり押し込んでも、刺さって見えるだけで通電していないか、最悪ショートする。エスケープも同じで、差し込み先が HTML 本文か、HTML 属性か、JS 文字列かで必要な変換規則が違い、HTML 用の変換を JS の差し込み口にそのまま使うと、無害に見える文字列のまま実際には別の形で壊れて漏れる。
DOM-based XSS — サーバーの出力エスケープが届かない経路
サーバー側のテンプレートを完璧にエスケープしても防げない経路がある。クライアント側の JS が API のレスポンスやクエリパラメータを innerHTML や document.write、jQuery の .html() に直接渡すと、ブラウザはその文字列を HTML としてパースし、中に含まれる <script> や onerror 属性を実行してしまう。React の JSX で {value} と書く分には自動でエスケープされるが、dangerouslySetInnerHTML はその名の通りこの安全装置を明示的に外す API で、Vue の v-html も同じ性質を持つ。
対策は、テキストとして表示するだけなら textContent(React なら通常の {} 出力)を使い、どうしてもユーザー入力由来の HTML を描画する必要がある場合だけ DOMPurify のようなサニタイザで許可するタグ・属性を絞ってから渡すことだ。「入力側で弾く」より「出力側でその文脈に応じた形に変換する」ことが原則になる。
最後の壁としての CSP
Content-Security-Policy(CSP)は、レスポンスヘッダ(Content-Security-Policy、推奨)または <meta> タグでブラウザに配信する、実行を許可するリソースの許可リストだ。script-src ディレクティブで許可したソース以外からのスクリプト実行——インラインの <script>、onclick のようなインラインイベントハンドラ、eval()——をブラウザ自身が拒否する。エスケープがどこかで漏れて悪意あるコードが HTML に紛れ込んでも、CSP が一致するソースなしと判定すれば実行されない。
# インラインスクリプトと eval を許可しない厳格な設定 Content-Security-Policy: script-src 'self'; object-src 'none' # レスポンスごとにランダムな nonce を発行し、 # 一致する