WebアプリをXSSとCSRF脆弱性から守る方法:本番環境での実践的振り返り

Security tutorial - IT technology blog
Security tutorial - IT technology blog

6ヶ月間の失敗と修正の記録

昨年、私が管理していたステージング環境が定期的なペネトレーションテストで指摘を受けました。レポートの冒頭に記載されていたのは、クロスサイトスクリプティング(XSS)クロスサイトリクエストフォージェリ(CSRF)でした。理論的には知っていた脆弱性です。しかし、自分が実際に構築したアプリで悪用されるのを目の当たりにすると、受け止め方がまったく違いました。

それからの6ヶ月間は、防御策の後付け実装、CSPポリシーの書き直し、フォーム処理の見直し、そして脆弱性の攻撃対象領域が徐々に縮小していく様子を観察する日々でした。これはOWASPのドキュメントをそのままコピーしたものではありません。本番環境で実際に測定可能な効果をもたらした具体的な変更点のまとめです――最初から手元にあればよかったと思うものを。

2つの脅威を理解する

XSS — アプリが攻撃者の代弁者になるとき

クロスサイトスクリプティングは、攻撃者が悪意のあるJavaScriptをページに注入し、他のユーザーがそのページを読み込むことで発生します。ブラウザはそのスクリプトがあなたが書いたものでないことを判断できません。あなたのドメインの完全な信頼とコンテキストのもとで、そのまま実行されてしまいます。

主な3つの種類:

  • 反射型XSS:ペイロードがURLやフォーム入力に含まれ、レスポンスにそのまま反映されるタイプ。
  • 蓄積型XSS:ペイロードがデータベースに保存され(コメント欄など)、そのページを読み込んだすべての訪問者に表示されるタイプ。規模が大きくなるほど危険度が増します。
  • DOMベースXSS:攻撃がブラウザ内で完結するタイプ。JavaScriptがlocation.hashなどのソースから読み取り、サーバーを一切経由せずDOMに書き込みます。

被害の範囲は広く、セッションハイジャック、認証情報の窃取、ページの全面改ざん、被害者には完全に本物に見えるフィッシングオーバーレイなどが挙げられます。XSSやCSRFを含むOWASP Top 10の主要なWeb脆弱性を体系的に把握しておくと、この種の攻撃に対する理解がさらに深まります。

CSRF — ブラウザを攻撃者の道具に変える手口

CSRFはブラウザが自動的に行う動作を悪用します。つまり、一致するすべてのリクエストにCookieを付与するという動作です。ユーザーがbank.comにログインしている状態で、攻撃者がbank.com/transferに送信する隠しフォームを含むページを読み込ませると、ブラウザはセッションCookieを一緒に送信します。サーバーは正規のリクエストと判断し、送金が実行されてしまいます。

コードの注入は必要ありません。CSRFはブラウザとサーバーの信頼関係を悪用し、被害者自身の認証情報を攻撃に利用します。

実践:XSSへの防御

1. 出力エンコーディング — 基本中の基本

HTMLにレンダリングするユーザー入力データはすべて、出力前にエンコードしなければなりません。これは絶対的なルールです。Jinja2(Flask/Django)を使ったPythonでは、デフォルトで自動エスケープが有効になっていますが、| safeフィルターを不用意に使うと意図せずこれを無効化してしまいます。

# 悪い例 — ユーザー入力を直接レンダリング
return f"<p>Welcome, {request.args.get('name')}</p>"

# 良い例 — テンプレートエンジンにエスケープを任せる
# Jinja2(自動エスケープ有効):
return render_template("welcome.html", name=request.args.get("name"))
# welcome.html: <p>Welcome, {{ name }}</p>  ← 自動エスケープ済み

JavaScriptのコンテキストにデータを注入する場合、HTMLエスケープだけでは不十分です。json.dumps()または専用のJSエンコーダーを使用してください:

import json

user_data = request.args.get("username", "")
# JSコンテキストへの安全な注入:
js_safe = json.dumps(user_data)  # クォートを追加し特殊文字をエスケープ

2. コンテンツセキュリティポリシー(CSP) — 多層防御

エンコーディングが漏れた場合でも(いつかは必ず起きます)、適切に設定されたCSPは注入されたスクリプトが実際に実行できることを制限します。私の環境で最も目に見える効果があった変更はこれでした。ヘッダー1つで、悪用可能性が大幅に低減されました。

# Nginx設定 — serverブロックに追加
add_header Content-Security-Policy \
  "default-src 'self'; \
   script-src 'self' 'nonce-RANDOM_NONCE_HERE'; \
   style-src 'self' 'unsafe-inline'; \
   img-src 'self' data: https:; \
   object-src 'none'; \
   frame-ancestors 'none';" always;

nonceアプローチにより、インラインスクリプトを安全に管理できます。リクエストごとに新しい暗号的ナンスを生成し、CSPヘッダーと<script>タグの両方に埋め込みます。正しいナンスを持たない注入スクリプトはすべてブロックされます。

import secrets

# リクエストハンドラー内:
nonce = secrets.token_hex(16)  # 32文字の16進数、例: "a3f9c2...d1"
# テンプレートに渡し、CSPヘッダーにも設定する
response.headers["Content-Security-Policy"] = (
    f"script-src 'self' 'nonce-{nonce}'"
)
# テンプレート内:
# <script nonce="{{ nonce }}">...</script>

3. DOMベースXSS — JavaScriptのコードを見直す

静的解析ツールはサーバーサイドのコードしかスキャンしない場合、このタイプを完全に見逃します。フロントエンドで次のようなパターンがないか確認してください:

// 危険 — URLフラグメントを直接DOMに注入
document.getElementById("output").innerHTML = location.hash.slice(1);

// 安全な代替手段
document.getElementById("output").textContent = location.hash.slice(1);

HTMLをレンダリングする必要がない場合は、innerHTMLの代わりにtextContentを使いましょう。HTMLの出力が本当に必要な場合は、サニタイザーを使用してください。DOMPurifyが標準的な選択肢です。積極的にメンテナンスされており、テストが充実していて、フットプリントも小さいです:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(untrustedHTML);
document.getElementById("output").innerHTML = clean;

実践:CSRFへの防御

1. 同期トークンパターン

古典的な防御手法ですが、今も必須です。サーバーサイドで秘密トークンを生成し、すべてのフォームに隠しフィールドとして埋め込み、送信時に検証します。攻撃者のサードパーティページは同一オリジンポリシーによってトークンを読み取れないため、エンドポイントを知っていても有効なリクエストを偽造できません。

# flask-wtfを使ったFlaskの例(CSRFトークンを自動処理)
from flask_wtf import FlaskForm
from wtforms import StringField

class TransferForm(FlaskForm):
    amount = StringField("Amount")

# テンプレート内:
# <form method="POST">
#   {{ form.hidden_tag() }}  <!-- csrf_tokenを注入 -->
#   {{ form.amount() }}
# </form>

JSON APIには少し異なるアプローチが必要です。カスタムリクエストヘッダーにトークンを入れてください。ブラウザはクロスオリジンのJSがカスタムヘッダーを設定するのをブロックします:

// クライアントサイド: CookieまたはメタタグからCSRFトークンを読み取る
const csrfToken = document.cookie
  .split("; ")
  .find(row => row.startsWith("csrftoken="))
  ?.split("=")[1];

fetch("/api/transfer", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "X-CSRFToken": csrfToken,
  },
  body: JSON.stringify({ amount: 100 }),
});

2. SameSite Cookie属性

モダンなブラウザはCookieのSameSite属性をサポートしており、クロスサイトリクエスト時にCookieを送信するかどうかを制御します。StrictまたはLaxに設定するだけで、アプリケーションレベルのコード変更なしにほとんどのCSRFシナリオを防ぐことができます:

# Flaskの場合:
app.config.update(
    SESSION_COOKIE_SAMESITE="Lax",    # または "Strict"
    SESSION_COOKIE_SECURE=True,        # HTTPSのみ
    SESSION_COOKIE_HTTPONLY=True,      # JSから読み取り不可
)

ほとんどのアプリではStrictよりLaxを選択してください。Strictは危険なクロスサイトの変更操作をブロックしますが、正当なナビゲーションも壊してしまいます。たとえば、メールのリンクからアプリにアクセスしたユーザーがログアウト状態になってしまいます。Laxはその不便さなしに重要な保護(クロスサイトPOSTのブロック)を提供します。

3. Originヘッダーの検証

状態を変更するリクエストに対しては、OriginまたはRefererヘッダーをサーバーサイドで検証することを二次的な防御層として加えましょう。軽量で効果的です:

from urllib.parse import urlparse
from flask import request, abort

ALLOWED_ORIGINS = {"https://yourdomain.com"}

def verify_origin():
    origin = request.headers.get("Origin") or request.headers.get("Referer", "")
    parsed = urlparse(origin)
    origin_base = f"{parsed.scheme}://{parsed.netloc}"
    if origin_base not in ALLOWED_ORIGINS:
        abort(403)

セキュリティヘッダーチェックリスト

CSP以外にも、ほぼゼロの手間で実質的な保護を追加できるHTTPヘッダーがいくつかあります。Nginxの設定に追加するだけで、セットアップは5分もあれば完了します。Linuxサーバーの強化チェックリストと組み合わせることで、アプリケーション層とインフラ層の両面から防御を固めることができます:

# Nginx — http {} または server {} ブロックに追加
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;

X-Frame-Options: DENYはアプリがiframeに埋め込まれることを防ぎます。これはクリックジャッキングを介した古典的なCSRF配信ベクターです。X-Content-Type-Options: nosniffは、ブラウザが宣言したContent-Typeとは異なる形でレスポンスの内容を推測することを防ぎ、コンテンツインジェクション攻撃の一種を遮断します。どちらも1行で設定でき、その効果は絶大です。

実装した内容をテストする

検証なき防御は単なる楽観主義です。上記のすべてを実装した後、以下のテストを実施しました:

  • OWASP ZAP(自動スキャナー)をステージングURLに対して実行 — ほとんどの反射型XSSや不足しているセキュリティヘッダーを数分で検出します。
  • Burp Suite Community — ライブのフォーム送信をインターセプトし、CSRFトークンを手動で除去して、サーバーが403を返すことを確認します。返さない場合は何かが壊れています。
  • securityheaders.com — URLを貼り付けると、レスポンスヘッダーの評価結果が採点付きで表示されます。無料、即時、セットアップ不要。
  • フロントエンドJSのinnerHTMLdocument.writeeval()呼び出しを手動でレビュー — grepが役に立ちます。

もう一つの視点:認証情報の管理

XSSとCSRFの防御を強化する過程で、サービスアカウントのパスワードも見直す必要があることに気づきました。XSS攻撃で盗まれたセッショントークンは深刻な問題です。それに加えて管理者パスワードが脆弱であれば、2つの問題が掛け合わさって壊滅的な結果をもたらします。強力なパスワードの生成と強度確認についても合わせて把握しておくと、認証情報全体のセキュリティレベルを底上げできます。

サーバーやデータベースの認証情報には、toolcraft.app/ja/tools/security/password-generatorのジェネレーターを使用しています。繰り返し利用している理由は、完全にブラウザ内で動作することです。パスワードがネットワーク経由で送信されることは一切ありません。覚える必要があるものには、発音しやすいオプションが本当に便利で、ランダムな記号の羅列ではなくtruv-6Xep-malkのような文字列を生成してくれます。

まとめ

6ヶ月間の取り組みの結果、厳格な出力エンコーディング、ナンスベースのCSP、すべての状態変更フォームへの同期トークン、そしてSameSite=Lax Cookieの組み合わせにより、攻撃対象領域が大幅に縮小しました。その後のペネトレーションテストでは、両カテゴリーともクリーンな結果が出ました。理論的な改善ではなく、測定可能な成果です。

これらの修正はどれも大げさなものではありません。設定と規律の問題です。出力をエンコードし、トークンを検証し、ヘッダーを設定する。難しいのは、コードベースが成長し新しい開発者が加わる中で、すべての入力ベクター、すべてのエンドポイントに対して一貫して適用し続けることです。CI内の自動テスト(デプロイのたびにZAPスキャンを実行)が、カバレッジが時間とともに低下するのを防ぐ鍵になります。

XSSとCSRFは古い脆弱性です。今も繰り返し発生するのは、修正が難しいからではなく、スピードを優先して開発しているときに見落としやすいからです。これでチェックリストとそれを裏付けるコードが手に入りました。

Share: