Tech Learning Daily

2026-09-01 (Tue) — 第 47 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Security 🌿 基礎 ⏱ 約 7 分

Rails は自動でエスケープしているのに、なぜ XSS はなくならないのか?

<%= @comment.body %> と書くだけで、Rails は自動的に HTML エスケープしてくれる。React も {} で値を出すだけなら自動でエスケープされる。それなのに、なぜ XSS(クロスサイトスクリプティング)は毎年 OWASP の脆弱性ランキングの常連であり続けるのか。原因は「エスケープを書き忘れる」ことだけではない。エスケープには、埋め込み先の文脈によって正しいやり方が変わるという、見落とされがちなルールがある。

🎯 3 行まとめ
  • 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 エンティティエンコードする。& < > " ' をそれぞれ &amp; &lt; のような実体参照に変換し、ActiveSupport::SafeBuffer というクラスで「この文字列はすでにエスケープ済みか」を追跡する仕組みだ。これは HTML 本文(タグとタグの間にテキストとして出す)という 1 つの文脈に対する変換にすぎない。

raw()html_safe<%== %> はこの安全装置を意図的に外すための API で、ユーザーが書いた HTML をリッチテキストとしてそのまま表示したい場合など、正当な用途もある。ただし対象がユーザー入力そのものだと、挙動は一変する。

<%# デフォルト: 自動エスケープされる(安全) %>
<%= @comment.body %>
<%# コメントに "<script>alert(1)</script>" が入っていても
    画面には文字列としてそのまま表示されるだけで実行されない %>

<%# raw / html_safe: 安全装置を外す(ユーザー入力では危険) %>
<%= raw @comment.body %>
<%# 上と同じ入力が、そのまま <script> タグとして解釈・実行される %>

つまり Rails の自動エスケープは「デフォルトの出力先が HTML 本文である限り」有効な安全装置であり、コードのどこかで rawhtml_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 のレスポンスやクエリパラメータを innerHTMLdocument.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 を発行し、
# 一致する