Dev Study
← Webセキュリティの実務
レッスン 5 / 12·約 9 分·第19期 Webセキュリティの実務

CSRF の防御は、別サイトのページが作れない信号を渡すこと

リクエストがアプリケーションに届くまで
  1. ブラウザURL入力今ここ
  2. DNS名前→IP
  3. ルータ経路選択
  4. ロードバランサ振り分け
  5. Nginx受け口
  6. アプリ処理今ここ
  7. DB保存

前回は、SameSite の判定単位が site であることを見ました。今回は、サーバが自分のページから来た要求を見分けるために何を照合するかを見ます。

まず結論

このコースの芯は「ブラウザが確かめられるのは『どこから来たか』までで、『本人が望んだか』はサーバが自分で確かめるしかない」です。CSRF の防御はこの境目にあり、サーバは Cookie ではなく、別サイトのページには作れない信号を要求の中から探して照合します。

信号は2種類あります。別オリジンからは読めない CSRF トークンと、スクリプトから書き換えられない Sec-Fetch-Site・Origin ヘッダです。どちらもブラウザの中で動く別サイトのページにだけ効く仕組みで、OWASP はヘッダで判定する場合も旧ブラウザ向けのフォールバックを必須としています。

? 読む前に予測

自前のサーバ b.test にログイン済みのブラウザで、(1)b.test 自身のフォームと (2)別サイト a.test のフォームから、同じ b.test/update へ POST しました。セッション Cookie(SameSite=None)はどうなっていたでしょうか。

当てることが目的ではありません。外した予測ほど記憶に残るので、直感で選んでください。

抽象原理

Cookie では区別できないので、別の信号を探す

SameSite などで制限しない限り、トップレベルのフォーム送信では、Cookie は要求を始めたページがどこであっても宛先のサイトの Cookie として付いていきます。サーバから見ると、本人が自分の画面で押したボタンと、別サイトのページが勝手に送らせたフォームは、同じ Cookie を持った同じ形の要求です。

ブラウザは「どこから来たか」を教えてくれます。しかし「本人が望んだか」は教えてくれません。そこでサーバは、別サイトのページには作れないものを要求の中に要求し、自分で照合します。作れない理由は2つあります。

1つは読めないこと。CSRF トークンはサーバが発行して自分の画面に埋めた乱数です。別オリジンのページは b.test に要求を送ることはできても、その応答を読めないので、画面に埋まった値を知りようがありません。OWASP は CSRF tokens prevent CSRF because without a CSRF token, an attacker cannot create valid requests to the backend server. と書きます。

もう1つは書けないこと。Fetch Standard §2.2.2 は、名前が sec- か proxy- で始まるヘッダと、Cookie・Origin などを forbidden request-header とし、スクリプトから設定させません。

These are forbidden so the user agent remains in full control over them.

Sec-Fetch-Site はこの接頭辞を持つので、値を決めるのはブラウザだけです。OWASP はこれを the primary signal for CSRF protection と呼びます。

別サイトのページが決められるものと、決められないもの
別サイトのページが決められる宛先・メソッド・本文
ブラウザが自動で足す
Cookieフォーム送信なら頼み手を問わず付く
Sec-Fetch-Site / Originスクリプトから書き換えられない
サーバが照合する
CSRF トークン別オリジンから読めないので本文に書けない
ヘッダの値決まった値と完全一致で見る。無いときは別の検証へ
  • ・どちらの信号も「ブラウザの中で動く別サイトのページ」を相手にしている
具体実物

同じ Cookie、違う判定

手元に b.test と a.test の2つのサイトを立てました(127.0.0.1、ローカル証明書)。b.test の GET /form はセッション Cookie sid と、セッションに結びつけた16バイトの乱数の CSRF トークンを発行し、トークンを hidden 欄に埋めたフォームを返します。POST /update は送られた値とセッションの値を crypto.timingSafeEqual で比べ、一致すれば accepted、それ以外は rejected を返します。Cookie はわざと SameSite=None にして、SameSite では止まらないようにしています。

server-log.jsonl(Chromium 153、1回目)から項目を抜き出して表にしたもの。sid は先頭8桁のみ表
要求 cookie csrf sec-fetch-site 判定
b.test のフォーム -> /update sid=32d29495 あり same-origin ACCEPT
a.test のフォーム -> /update sid=32d29495 なし cross-site REJECT

2行の cookie 列は同じ値です。区別したのは csrf 欄の有無で、ブラウザが付けた sec-fetch-site も same-origin と cross-site に分かれています。Playwright 同梱版(市販版ではない)の Chromium 153・Firefox 155・WebKit 26.6 で各2回、判定は同じでした。

✓ ここまでの確認

a.test のスクリプトが、b.test への fetch に自分で `Sec-Fetch-Site: same-origin` を付けようとしました。実測では何が起きましたか。

抽象原理

「作れない」は、誰に対してか

2つの信号はどちらも、ブラウザの中で動く別サイトのページが作れない、という意味で作れません。相手を変えると成り立たなくなります。

  • ブラウザ以外: curl はどんなヘッダでも付けられます。ただし被害者のブラウザの Cookie を持たないので、その要求は本人のものとして扱われません。
  • 同じオリジンの中で動くスクリプト: 画面を読めるので CSRF トークンも読めます。OWASP は冒頭で Remember that Cross-Site Scripting (XSS) can defeat all CSRF mitigation techniques! と書いています。

ヘッダの信号には、付かない場合もあります。W3C の Fetch Metadata は、要求先が potentially trustworthy な URL(https や localhost など)でなければ Sec-Fetch-* を付けません。Origin について Fetch Standard §3.2 はたとえば GET・HEAD 以外の要求に使うとしつつ Due to compatibility constraints it is not included in all fetches. と書き、referrer policy によっては値が null になります。OWASP も、Sec-Fetch-* を送らない旧ブラウザがあるとして a fallback to standard origin verification headers is a mandatory requirement としています。

Sec-Fetch-Site の値には same-site もあります。同じ site(scheme + eTLD+1)に属する別オリジン、たとえば兄弟サブドメインからの要求は cross-site になりません。「cross-site だけを拒否する」方針は、同じ site の中にいる相手を通します。

具体実物

ページからは書けない、curl からは書ける

同じ Sec-Fetch-Site を、ブラウザの中のスクリプトと curl から付けようとしました。

実験5・5b・curl のログから項目を抜き出して表にしたもの(ブラウザは3エンジン×2回で同じ)表
送り手 付けようとした値 届いた sec-fetch-site 備考
a.test のスクリプト(no-cors) same-origin cross-site 例外なし
a.test のスクリプト(cors) same-origin cross-site OPTIONS 行なし
curl same-origin same-origin cookie なし、REJECT

cors モードでは、new Headers() にはキーとして入りました(headers-object-keys=[content-type,sec-fetch-site])。それでも要求には載らず、OPTIONS 行もありません(fetch は応答が CORS で許可されず TypeError)。Fetch Standard §5.1 は、guard が request の Headers に forbidden request-header を追加しようとすると検証が false を返し、手順はそこで return すると書いています。例外を投げずに黙って捨てるので、スクリプトはヘッダが捨てられたことに気づけません。

curl からは付けた値がそのまま届きました。Sec-Fetch-Site と Origin が意味を持つのは、ブラウザが付けた場合だけです。

✓ ここまでの確認

「主要ブラウザが Sec-Fetch-Site を送るので、CSRF トークンや他の検証はもう要らない」という説明は妥当ですか。

⇄ 2つを見比べる
具体例 A

CSRF トークン

サーバが乱数を発行して自分の画面に埋め、送られた値と照合します。別オリジンのページは画面を読めないので値を当てられません。同じオリジンの中で動くスクリプトは画面を読めるので、この防御は効きません。

誰が値を持てるか表
相手 値を知る手段 防げるか
別サイトのページ 無い 防げる
同じオリジンで動くスクリプト 画面を読める 防げない
具体例 B

TCP の初期シーケンス番号(ISN)

RFC 9293 §3.4.1 は ISN を秘密鍵を入れた関数で作るよう求め(SHLD-1)、その関数は外から計算できてはならない(MUST-9)と定めます。想定する脅威は経路外の相手による当て推量です。経路上でパケットを見られる相手には番号がそのまま見えるので、効きません。

RFC 9293 §3.4.1設定ファイル
There are security issues that result if
an off-path attacker is able to predict or guess ISN values
...
ISN = M + F(localip, localport, remoteip, remoteport, secretkey)

片方は Web アプリ、片方は TCP。守っている範囲の境目は、どちらもどこに引かれているでしょうか。

「その値を見られる位置にいる相手」に対して、それぞれの仕組みが何をしてくれるかを考えてください。

この章のまとめ

  • Cookie は本人の要求にも偽の要求にも付く。Cookie だけでは区別できない。
  • 信号は2種類。別オリジンから読めない CSRF トークンと、スクリプトから書けない Sec-Fetch-Site・Origin。
  • スクリプトが Sec-Fetch-Site を付けようとしても、ヘッダの追加では例外もプリフライトも起きずに捨てられる。curl なら任意の値を付けられる。
  • どちらも「ブラウザの中の別サイトのページ」にだけ効く。同じオリジンの中のスクリプトは CSRF トークンを読める。
  • ヘッダは付かない場合がある。OWASP はヘッダで判定する場合もフォールバックを mandatory とする。「トークンは要らなくなった」ではない。
  • 部分一致とは別物。書けないヘッダを完全一致で見て、欠落時の扱いを決める。

自分の現場で確かめる

読んだだけでは身につきません。手元の環境で実際に見てみてください。

理解度チェック

読んだ直後は「分かった」と感じやすいものです。実際に解いて確かめてください。

問題 1 / 3正解: 0

OWASP CSRF Prevention Cheat Sheet が、同期トークンパターンの CSRF トークンに求めている性質として挙げていないものはどれですか。

用語集(3語)
CSRF トークン(同期トークンパターン)
サーバがセッションに結びつけて発行し、正規の画面に埋めて送らせる予測できない値。別オリジンのページはその画面を読めないので、値を用意できない。
Sec-Fetch-Site
要求を始めた側のオリジンと宛先のオリジンの関係を示すヘッダ。値は cross-site・same-origin・same-site・none。potentially trustworthy な URL への要求にだけ付く。
forbidden request-header
Fetch Standard §2.2.2 が定める、スクリプトから設定できない要求ヘッダ。Cookie・Origin などの名前と、sec- / proxy- で始まる名前が含まれる。要求の Headers に追加しようとすると例外なしに捨てられる。