クロスサイトリクエストフォージェリ(CSRF、CWE-352):仕組みと防ぎ方
別のサイトのページから、ログイン中のユーザーのブラウザにあなたのアプリのデータを変更するリクエストを送らせることができ、ブラウザはセッション Cookie を自動で付けるため、Cookie しか確認しないルートはその操作を実行してしまいます。シンクロナイザートークン、SameSite Cookie、Sec-Fetch-Site の確認でこれを防ぐ方法を、Flask と Flask-WTF の例で解説します。
わかりやすい解説 (ELI5)
あなたの会社には、あなたの「発送」トレイに置かれた署名入りの依頼票なら何でも処理する郵便室があります。そのトレイに手が届くのはあなただけなので、郵便室は依頼票を信頼しています。帰りがけに来客から、封をした封筒をトレイに入れておいてほしいと頼まれます。中には「私の荷物の配送先をすべて変更してください」という依頼票が入っています。郵便室はあなたのトレイでそれを見つけ、あなたが書いたものだと思って処理します。CSRF も同じで、ブラウザはあなたの銀行へのリクエストに、別のサイトが始めたものも含めて、必ずセッション Cookie を付けます。対策は、あなたの会社のページだけが押せる秘密の印鑑で、郵便室は印のない依頼票を断ります。
主要な概念と専門用語
- アンビエントな認証情報
- ブラウザは、どのページが始めたかに関係なく、サイトへのリクエストに Cookie や HTTP 認証を付けます。
evil.exampleからapp.exampleへのリクエストにも、Cookie のSameSite属性が止めない限りapp.exampleのセッション Cookie が付きます。 - Cookie の SameSite 属性
Strictはクロスサイトのリクエストでは常に Cookie を送らず、Laxはトップレベルの GET ナビゲーションでだけ送り、Noneは常に送ります(Secureが必要)。Chromium は属性のない Cookie をLaxとして扱いますが、Firefox と Safari は扱いません。また「同一サイト」には隣接するサブドメインも含まれます。- シンクロナイザートークン
- セッションに結び付いたランダムな値で、サーバーはフォームやページごとに埋め込み、安全でないリクエストのたびに要求します。別のサイトは読めないので、正しいリクエストを偽造できません。Flask-WTF の
CSRFProtectはcsrf_token()で発行し、csrf_tokenフィールドかX-CSRFTokenヘッダーを確認します。 - Fetch Metadata(Sec-Fetch-Site)
- 最近のブラウザはすべてのリクエストで
Sec-Fetch-Site: cross-site、same-site、same-origin、noneのいずれかを送り、ページからは偽造できません。same-origin以外の安全でないメソッドを拒否すれば、サーバー側の 1 つの確認で CSRF を防げます。 - GET による状態変更
- GET でデータを変えるルートは
<img>タグやリンクで起動でき、SameSite=Laxでもトップレベルの GET ナビゲーションでは Cookie が送られます。request.valuesはフォーム本文に加えてクエリ文字列も受け付けます。
ステップ・バイ・ステップの攻撃フロー
被害者がログインしている
app.example のセッション Cookie を持っており、SameSite=None が付いているか、Lax を既定にしないブラウザで属性なしのまま送られます。
攻撃者が用意したページを開く
https://app.example/account/email に送信する隠しフォームがあり、読み込み時に自動で送信されます。無害なテスト値 email=test@example.com を送るフォームはこの場合を示しています。
ブラウザが Cookie 付きでリクエストを送る
リクエストには被害者のセッションが付いており、フォームが別のサイトにあったことをサーバーが知る手がかりはありません。
サーバーがアカウントを変更する
メールアドレスが攻撃者のものに変わり、攻撃者はパスワードを再設定してアカウントを乗っ取ります。2008 年に Zeller と Felten は、ING Direct、YouTube、The New York Times でこの種の CSRF を示しました。
ソースコード比較:脆弱 vs 堅牢化
# app.py: ユーザーの意思を示す証拠はセッション Cookie だけ
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET でも動き、request.values はクエリ文字列も読む
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
# app.py: トークン、SameSite Cookie、Fetch Metadata を併用
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# POST には csrf_token が必須。フォームは {{ csrf_token() }} を描画し、fetch() は X-CSRFToken を送る
csrf = CSRFProtect(app)
# 別サイトが始めた書き込みはルートの前で拒否する
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
abort(403)
# POST のみ、データはフォーム本文から
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
エンジニアリング&システム堅牢化チェックリスト
- 状態の変更は POST、PUT、PATCH、DELETE だけで行い、フォームのデータは本文(
request.form)から読む。request.valuesやクエリ文字列からは読まない。 - フレームワークの CSRF ミドルウェア(
CSRFProtect、Django のCsrfViewMiddleware、Spring Security のcsrf())をアプリ全体で有効にし、すべてのフォームと AJAX リクエストにトークンを含める。 - セッション Cookie には
Secure、HttpOnly、SameSite=LaxまたはStrictを付ける。SameSite=Noneはクロスサイトの iframe 内で動く必要がある Cookie だけに使う。 Sec-Fetch-Siteヘッダーがcross-siteまたはsame-siteの安全でないリクエストを拒否する。- 状態を変える各ルートにトークンなしのリクエストを送り、400 か 403 を期待する CI テストを追加する。