CSRFの仕組み
要求が送られること、JavaScriptが応答を読めること、業務状態が変わることを分けて説明します。
説明
CSRFでは、利用者のブラウザが持つ認証情報を伴って、利用者の意図しない要求が送られることが問題になります。Cookieがあることはログイン状態の判断材料ですが、その操作を利用者が意図した証拠にはなりません。
残高を10減らすHTTP APIへ、別オリジンを示す要求を送ります。Cookieの有無、GETとPOST、CORSのプリフライトを比較し、応答と残高を対応付けます。
要求が送られること、JavaScriptが応答を読めること、業務状態が変わることを分けて説明します。
1開始時の残高を読む
aliceとbobの残高は100ずつです。このAPIには最初、操作の意図を確認するCSRF検証がありません。
from sd04 import CSRF
lab = CSRF()独立した残高APIを用意する
print(lab.state())認証CookieとCSRF検証の役割を分けて比較します。以後のHTTPクライアントは指定した固定ヘッダーだけを送ります。
2別オリジンからCookieなしで要求する
Originを他オリジン、認証Cookieをなしにします。
print(lab.request(session=None認証Cookieを付けずに要求する))
print(lab.state())401で残高は変わりません。このAPIはセッションを持たない相手の操作を受け入れていません。
3同じ要求へ認証Cookieを付ける
Originは同じ他オリジンのまま、aliceのCookieを付けます。
print(lab.request()他オリジンを示すCookie付きPOSTを送る)
print(lab.state())200でaliceが90になります。ログインしているという条件だけで更新すると、要求元を確認していない処理が通ります。
4Cookieと要求元の記録を確認する
受信したメソッド、Cookieの有無、Origin、Fetch Metadataを読みます。
print(lab.last受信側が見た要求の記録を読む)CookieありでもOriginはother.example.testです。Sec-Fetch-Siteもcross-siteですが、この未対策APIは検査していません。手動HTTPクライアントでのヘッダー設定と、ブラウザが付ける条件は別に確認します。
5GETを状態変更へ使わない
同じ操作先へGETを送ります。
print(lab.request(method="GET"状態変更先へGETを送る))
print(lab.state())405で残高90を保ちます。GETを拒否することは必要な設計の一つですが、POSTもフォームなどから送れるため、メソッドだけでCSRF対策が完了するわけではありません。
6プリフライト自体では更新しない
OPTIONSを送り、業務操作が実行されないことを確認します。
print(lab.request(method="OPTIONS"プリフライトのメソッドを送る))
print(lab.state())204で残高は同じです。このAPIはCORS許可ヘッダーを返しません。ただし単純なフォーム形式のPOSTはプリフライトを伴わず送られる場合があります。
7フォーム形式のPOSTを再度送る
フォームのapplication/x-www-form-urlencoded形式で、同じCookie付きPOSTをもう一度送ります。
print(lab.request(body="amount=10"フォーム形式の本文で状態変更を要求する))
print(lab.state())
lab.close()aliceは80です。ブラウザで応答の読取りがCORSにより拒否されても、送信済み要求の更新が取り消されるわけではありません。Cookieの送信可否にはSameSiteなどの条件も関わります。
まとめ
認証Cookieだけでは、利用者の操作意図を確認できません。
メソッド、Cookie、要求元、保存結果を対応付けます。
CORSの応答読取り制限をCSRF防止の代わりにしません。
操作履歴と解説を振り返れます。
Python 3.15 / HTTP API・SQLite / 固定のCookieと要求
from sd04 import CSRF
lab = CSRF()
print(lab.state())[('alice', 100), ('bob', 100)]print(lab.request(session=None))
print(lab.state())(401, {'error': 'session'})
[('alice', 100), ('bob', 100)]print(lab.request())
print(lab.state())(200, {'subject': 'alice', 'amount': 90})
[('alice', 90), ('bob', 100)]print(lab.last){'method': 'POST', 'cookie_present': True, 'origin': 'http://other.example.test', 'site': 'cross-site'}print(lab.request(method="GET"))
print(lab.state())(405, {'error': 'method'})
[('alice', 90), ('bob', 100)]print(lab.request(method="OPTIONS"))
print(lab.state())(204, None)
[('alice', 90), ('bob', 100)]print(lab.request(body="amount=10"))
print(lab.state())
lab.close()(200, {'subject': 'alice', 'amount': 80})
[('alice', 80), ('bob', 100)]演習完了