Edited at

これで完璧!今さら振り返る CSRF 対策と同一オリジンポリシーの基礎


✎ 基礎知識編


CSRF とは何か?

CSRF (Cross-Site Request Forgeries) を意訳すると 「サイトを跨ぐ偽造リクエスト送信」 です。

簡単に言うと,罠サイトを踏んだ結果,自分が無関係な別のサイト上で勝手にアクションをさせられる攻撃です。具体的には,ネットサーフィンをしているうちに知らない間に自分のIPアドレスから掲示板に犯罪予告が書かれていた,といった被害を受けます。

この攻撃を防ぐ責任は「無関係な別のサイト(具体例では掲示板)」側にあります。Web サイト作成者には,利用者が意図しない操作を勝手に実行されないように,利用者を守る責任があります。

また上図からも分かる通り,この攻撃の最大の特徴はアカウントがハッキングされたというわけではないということです。ログイン状態の利用者のWebブラウザを利用して攻撃が行われている,というのが重要です。


オリジンとは何か?

CSRF を語るにあたって避けられないのが Origin(オリジン) という用語です。自分のサイトか無関係な別のサイトかを判定するために,以下の要素を組み合わせたものが使用されます。

例: https://example.com:443



  • スキーム https



    • https または http



  • ホスト example.com


  • ポート 443


    • 省略された場合,HTTPSの場合は443,HTTPの場合は80が既定値



これらをまとめて Origin(オリジン) と言います。これらの成分が一致している場合は同じサイト,1つでも異なる場合は別のサイトと見なされます。


同一オリジンポリシーとは何か?

Web ブラウザは,異なるオリジン間で好き勝手リクエストを飛ばされないよう, 同一オリジンポリシー (Same-Origin Policy) というものを設けています。

MDN から重要な部分を引用します。




  • 異なるオリジンへの書き込みは、概して許可されます。例えばリンクやリダイレクト、フォームの送信などがあります。まれに使用される HTTP リクエストの際はプリフライトが必要です。

  • 異なるオリジンの埋め込みは、概して許可されます。例は後述します。


  • 異なるオリジンからの読み込みは一般に許可されません が、埋め込みによって読み取り権限がしばしば漏れてしまいます。例えば埋め込み画像の幅や高さ、埋め込みスクリプトの動作内容、あるいは埋め込みリソースでアクセス可能なものを読み取ることができます。


簡単に言うと, 異なるオリジンの Web サイトに対し,HTTP リクエストは送ることはできるが,その結果の読み取りはできない ということが書かれています。


CSRF 対策として結局何を守ればいいのか?

つまり,何もしなくても「結果の読み取り」の部分に関しては Web ブラウザが守ってくれるのです。CSRF 対策を明示的に行わないと守られないのは,「HTTP リクエストを送る」の部分です。

HTTP リクエストを送られるだけで問題のある操作…そう,「掲示板に記事を投稿」「パスワードを変更」など,データベース上に何らかの書き込みを行ったり,あるいはメールを送信したりする,副作用を起こすものです。こういったものは CSRF 対策で防がなければなりません。


  • HTML フォーム送信に関しては,あくまで送信を行うだけで結果をページ内の JavaScript で読み取ることが無いため,同一オリジンポリシー制御によって一切保護されません。


  • XMLHttpRequestfetch() に関しては,結果の取得部分で必ずエラーが発生してブロックされますが,リクエスト自体は送ることができてしまいます。

CSRF 対策の方針をまとめると,異なるオリジンの Web サイト上にある,副作用を目的とした HTTP リクエストをブロックすればいいということになります。


🔥 対策編

対策はサーバサイドで行いますが,それに適合させるために一部フロントエンド側の協力も必要です。

上記から代表的な3つをピックアップします。


トークンを検証する方法

ステートレス
安全性


アプリケーション形式
利用できるかどうか

HTMLフォーム

Web API

総合評価

サーバサイドで トークン と呼ばれる予測不可能な値を発行し,それをHTMLフォームに埋め込みます。古典的な方法でどのブラウザでも同じように動きますが, Web API の場合はステートレスにするほうが望ましいとされるため使いづらいです。

<form method="post" action="/posts">

名前: <input type="text" name="name" value=""><br>
本文: <textarea name="body"></textarea>

<input type="hidden" name="csrf_token" value="da39a3ee5e6b4b0d3255bfef95601890afd80709">
</form>


なぜトークンで対策できるのか?

攻撃者の目線で考えてみましょう。CSRF 攻撃を行うためには,正規のサイトと同様の HTML フォームを攻撃者サイト側で設ける必要があります。CSRF トークンがない場合は簡単にできてしまいますね。

<form id="form" method="post" action="https://example.com/posts">

<input type="hidden" name="name" value="名無しさん">
<input type="hidden" name="body" value="○○殺す">
</form>

<script>
document.getElementById('form').submit();
</script>

ところがここに CSRF トークンが必須となった場合はどうなるでしょうか?予測不可能な値なので,どうにかして取得しないといけません。


fetch() の例

const response = await fetch('https://example.com/posts', { method: 'GET', mode: 'cors' });

const html = await response.text();
const [, token] = html.match(/<input type="hidden" name="csrf_token" value="(.+)">/) || [];

console.log(token);


しかし,このコードは途中でエラーが投げられて失敗します。なぜなら



  • 異なるオリジンからの読み込みは一般に許可されません


という大原則があるからです。


トークンの生成方法

大きく分類すると,以下の2つの選択肢があります。


  • 固定トークン(ログイン完了後ずっと固定値になる)

  • ワンタイムトークン(1回の送信ごとに変更されていく)

一見ワンタイムトークンのほうが安全に見えるかもしれませんが,推測不可能という条件のみを満たしていれば十分なので,ワンタイムである必要性はありません。ワンタイムだとページリロードでの重複送信を防ぐこともついでにできてしまいますが,これが逆に不便になることもあり,また CSRF トークンの責務を超えた制御になってくるので,著者個人としては固定トークンを推奨しています。

固定トークンとしては以下のようなパターンがあるでしょう。


  • ランダム文字列を生成してセッションに格納し,繰り返して使う

  • セッション ID のハッシュ値を使う

  • セッション ID をそのまま使う (非推奨)

実例として,PHP の Laravel フレームワークではランダム文字列生成が採用されていますが,著者個人的にはセッション ID のハッシュ値でも十分かな,という意見です。但しセッション ID をそのまま使用するのは,XSS 脆弱性から即セッションハイジャックを受けるなどの二次被害にも繋がるので,万が一の保険としてハッシュ関数を通しておくことを推奨します。


[PHPの例] ランダム文字列

<?php

session_start();

if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = sha1(random_bytes(30));
}

?>

...

<input type="hidden" name="csrf_token" value="<?=$_SESSION['csrf_token']?>">



[PHPの例] セッションIDのハッシュ値

<?php

session_start();

?>

...

<input type="hidden" name="csrf_token" value="<?=sha1(session_id())?>">



[PHPの例] サーバサイドでの判定(セッションIDのハッシュ値を使う場合)

<?php

session_start();

if ($_POST['csrf_token'] !== sha1(session_id())) {
exit('invalid token');
}



固有の HTTP ヘッダを検証する方法

ステートレス
安全性


アプリケーション形式
利用できるかどうか

HTMLフォーム

Web API

総合評価

以下のような,通常のフォーム送信には付与されない固有の HTTP ヘッダをフロントエンド側で付与し,サーバサイドでそれが送られてきているかをチェックする方法です。この方法は Web API でのみ利用できます。


慣習的に古くから使われているヘッダ

X-Requested-With: XMLHttpRequest



JWT 認証で使用されるヘッダ

Authorization: Bearer XXXXX


(JWT 認証を行う場合で且つ副作用を発生するエンドポイントがすべて認証必須の場合,別段 CSRF 対策を行わなくても自然に対策できているパターンが多いです)

以下では前者を使った実装例を示します。


fetch() の例

const response = await fetch('/posts', {

method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: document.getElementById('name').value,
body: document.getElementById('body').value,
}),
});
const json = await response.json();

console.log(json);



[PHPの例] サーバサイドでの判定

<?php

if (!isset($_SERVER['HTTP_X_REQUESTED_WITH'])) {
header('Content-Type: application/json', true, 400);
exit('{"error":"invalid request"}');
}



なぜ固有の HTTP ヘッダで対策できるのか?

以下の2つの仕組みによって守られています。


  • HTML フォーム送信に関しては,一切の余分な HTTP ヘッダの付与が許可されていない。


  • XMLHttpRequestfetch() では自由に HTTP ヘッダを付与できるが, クロスオリジンのリクエスト先に 固有の HTTP ヘッダ を付与しようとすると プリフライトリクエスト によってブロックされる。

プリフライトリクエストに関しては少し難しくなるので,補足の項で説明します。簡単に済ませるなら「リクエスト前の事前チェックに落とされる」と認識しておいてください。


Origin ヘッダを検証する方法 (追記)

ステートレス
安全性


アプリケーション形式
利用できるかどうか

HTMLフォーム

Web API

総合評価

サーバサイドの実装のみで対策でき,非常に低コストかつ汎用性の高い方法です。近年まであまり提唱されてこなかったようですが,数年後には主流になっていそうです。

Origin ヘッダは以下のような形で Web ブラウザから送られてきます。この値は現在閲覧中の Web サイトのオリジンを指しており,Chrome エクステンション等を使わない限り 偽装することが不可能 です。そのためこの値をサーバサイドで信用し,意図しないオリジンからのリクエストをシャットアウトすることができます。

Origin: https://example.com

ブラウザによって 同一オリジンの場合は Origin ヘッダを付与しない という動きをする場合があるため,Origin ヘッダが送信されてきていて,かつ許可しないオリジンであれば無効化」という処理をサーバサイドに書く必要があります。また,GET リクエストの場合にはクロスオリジンでも付与されない点に注意が必要です。


[PHPの例] Origin ヘッダの検証

<?php

if (isset($_SERVER['HTTP_ORIGIN']) && $_SERVER['HTTP_ORIGIN'] !== 'https://example.com') {
exit('invalid origin');
}



どの方法が安全か?

ステートレスで済むトークンを使わない方法は優秀ではありますが,Web ブラウザ自体に脆弱性が見つかった場合にでも比較的安全と言えるのはトークンを使った方法です。実際に Origin ヘッダは偽装することが不可能」という大前提が崩れる脆弱性 が 2018 年にも見つかっています。

とはいえ Web API においてステートレスにできる恩恵は非常に大きいので,ある程度 Web ブラウザの潜在的な脆弱性には目を瞑り,また複数の対策を組み合わせて対応するというのも一つの考え方だと思われます。

また最近では, Same-Site CookieFetch Metadata といった新たな対策も取れるようになってきています。潜在的な脆弱性に対する安全性とステートレスによる利便性を両立したい場合は,積極的に組み合わせて採用していくといいでしょう。


ℹ️ 同一オリジンポリシーに関する補足

ここから先は,CSRF には直接関連しませんが,同一オリジンポリシーに関する重要な補足を書いていきます。少し難易度は高くなるので,初心者の方は雰囲気だけ掴んでいただければ問題ありません。


異なるオリジンからの読み取りを許可する方法

ここまで「異なるオリジンからの読み取りはブロックする」という方針で書いていましたが,API を外部に開放したり,また API のオリジンを https://api.example.com のように別のものにしている場合もあるでしょう。そういったケースでは,異なるオリジンからのアクセスを部分的あるいは全面的に許可する必要があります。


Access-Control-Allow-Origin ヘッダ

最も推奨される方法です。これをレスポンスヘッダに含めると,指定したオリジンからの読み取りを許可することができます。


あらゆる場所からのアクセスを許可する場合

Access-Control-Allow-Origin: *



https://api.example.com としてAPIを公開しているが,https://example.com からの読み取りを許可したい場合

Access-Control-Allow-Origin: https://example.com


限定的に許可する場合,運用方法は2通りあります。



  • Access-Control-Allow-Origin: https://example.com のように許可するオリジンのみを常に返す

  • Web ブラウザからのリクエストに含まれる Origin ヘッダを見て,正規表現などを使って Access-Control-Allow-Origin: <Originヘッダと同一の値> を返すかどうかを決める


    • 但し Origin ヘッダを偽装可能な脆弱性等を考慮するとややリスクあり




JSONP

現在では推奨されない方法です。

普通に XMLHttpRequest 等を使うと読み取りがブロックされてしまいますが, <script> 要素での読み込みはクロスオリジンでも利用できるという抜け穴を使います。サーバサイドは JSON を返す代わりに,JSONと同等の JavaScript オブジェクトを指定した関数で受け取って実行する JavaScript コードを返します。


リクエスト

<script src="https://api.example.com/posts?callback=callbackXXXXX&name=xxx&body=xxx"></script>



レスポンス

callbackXXXXX({"result": "success"});


jQuery の場合,<script>要素の動的生成,コールバック関数の動的生成,それらの後始末が自動で行われます。

ただご覧の通り,この方法は欠陥だらけです。


  • リクエストメソッドは GET しか使えない

  • サーバサイドは,クライアントサイドで定義されている任意の関数を実行するように仕向けることができる

セキュリティ的に問題があるため,現在では使われることはほとんどありません。


プリフライトリクエスト

プリフライトリクエスト(Pre-Flight Request)とは, XMLHttpRequest または fetch() を使用した際,クロスオリジンのリクエスト先に,事前にリクエストしていいかの確認を行うために飛ばされるリクエストのことです。OPTIONS という HTTP メソッドでの特殊なリクエストが先に飛び,サーバサイドから「このオリジンからのリクエストは認める」という意図で Access-Control-Allow-Origin を含んだ 200 OK が返ってくるのみ本来のリクエストが飛ぶようになっています。

プリフライトリクエストのフォーマット詳細に関してはこの記事では割愛します。


プリフライトリクエストが免除される場合

MDN によると, XMLHttpRequest または fetch() を利用したクロスオリジンのリクエストには基本的にプリフライトが付きますが,以下の条件をすべて満たす場合のみ免除されることになっています。


  • HTTP 動詞が GET HEAD POST のいずれかである

  • HTML フォーム送信などで自動でセットされるものや,一部の安全であると許可されているもののみから HTTP ヘッダー群が構成されている


    • X-Requested-With: XMLHttpRequest は許可されていない




  • Content-Type として,フォーム送信でも自動的に利用される application/x-www-form-urlencoded
    multipart/form-data,および安全と見なされる text/plain のいずれかが指定されている


    • Content-Type: application/json は許可されていない



  • (ほか2点省略)

プリフライトリクエストが免除される条件は,上記の通り非常に厳しいです。ざっくりまとめると,

「HTML フォーム送信でできることと同等の場合はプリフライトリクエストは不要,その範疇を超える場合は必要」

となります。


プリフライトリクエストを避けてパフォーマンスを向上させる

プリフライトリクエストは RTT (Round-Trip Time) を倍加させてしまうため,Web アプリケーションのパフォーマンス劣化を引き起こす要因になります。対策としては以下のようなものが挙げられます。


クロスオリジンを回避する

CSRF 対策の文脈では必ずクロスオリジンになりますが,自サイトからの正規利用の場合であったとしても,api.example.com など API サーバに専用のドメインを当てている場合にはクロスオリジンになってしまいます

しかしここで, API 専用のドメインを用意しないという選択を取ることができます。例えば api.example.com はパス分岐を利用して example.com/api とし,ドメインを共通化することができます。この場合クロスオリジンとはならず,プリフライトリクエストは一切発生しません。

もしこれを採用する場合,バックエンドでは HTML サーバと API サーバをきっちり分割しておき,ロードバランサでパスを見てアクセス先を振り分ける方法が一番モダンで優れていると言えるでしょう。


GET リクエストでは CSRF 対策を外す

大半の Web アプリは, GET リクエストさえ高速に動けばユーザ満足度は下がることはないでしょう。多少投稿の送信に時間がかかっても,投稿一覧の取得などに時間がかからなければストレスは少ないはずです。

非常に厳しい条件でありながら,クロスオリジンでも実は CSRF 対策用の X-Requested-With ヘッダを外せば GET リクエストの大部分はプリフライトを回避できることが分かります。 GET の場合は送信コンテンツが存在しないので Content-Type ヘッダも含まれません。

CSRF 対策の目的は「意図しない副作用を起こさせない」ことであるため,クリティカルな副作用が起こらないエンドポイントに関しては CSRF 対策を捨てても良いのです。もしこの方法を採用する場合,アプリケーション側で以下の HTTP メソッドの使い分けを徹底しましょう。


  • GET … 基本的にデータの取得のみで,勝手に実行されるとマズい副作用は起こさない


  • POST PUT DELETE PATCH … 副作用を認める

なお Origin ヘッダの検証を選択する場合,クロスオリジンでも GET リクエストには Origin ヘッダが付与されないことから,元から CSRF 対策が外された状態になっていることに注意しましょう。


プリフライトリクエストへのサーバサイドでの対応方法

プリフライトリクエストに自前で対応するのは少し骨が折れるため,ライブラリを使用しましょう。各言語,各フレームワークごとに実装はたくさんあると思いますが,ここでは PHP および Laravel 向けの具体例を紹介します。


サードパーティクッキーの書き込み

既に紹介した図をもう一度掲載します。クロスオリジンでありプリフライトリクエスト無しの場合, Access-Control-Allow-Origin ヘッダの有無に関わらずリクエスト自体は通ってしまう ✔ 部分に注目してください。



既に HTML フォームと XMLHttpRequest に関して,クロスオリジンである場合にプリフライトリクエストの有無について差異が発生することは説明しました。


  • HTML フォーム … プリフライトリクエストは一切発生しない

  • XMLHttpRequest … 固有ヘッダが含まれる場合などにプリフライトリクエストが発生する

実は重要なもう1点の差異があります。それはサードパーティクッキーの書き込みに関してです。サードパーティクッキーとは,アクセスしたサイトとは別のサイトのクッキーのことですが,これはトラッキング広告やシングルサインオンシステムなどで実際に使用されています。


  • HTML フォーム … サードパーティクッキーの書き込みができる

  • XMLHttpRequest … サードパーティクッキーの書き込みができない

シングルサインオンに関しては,以下の記事で詳しく実装例を解説しています。


⭐ 図解によるまとめ

CSRF,プリフライトリクエスト,Access-Control-Allow-Origin の関係性を整理しましょう。


XMLHttpRequest でのプリフライトリクエストありのパターン

副作用目的の API リクエストで,CSRF 対策として固有ヘッダ X-Requested-With を付与したものはこちらに該当します。また X-Requested-With の代わりに Origin の検証を採用しているがペイロードが Content-Type: application/json になっているものもこちらに該当します。このケースにおける CSRF 対策としては,プリフライトレスポンスで Access-Control-Allow-Origin を適切に返すことで成立します。

(但し Origin の検証の場合,プリフライトレスポンスで誤って許可してしまった場合も,次のメインリクエストを受け付けるタイミングで Origin を検証すれば防げます)


  • プリフライトレスポンスの Access-Control-Allow-Origin は,次のリクエストが実行可能かどうかを左右します。

  • メインレスポンスの Access-Control-Allow-Origin は,結果をフロントエンドで読み取れるかどうかだけに関わり,サーバサイドでアクションが実行されるかどうかには影響しません。


XMLHttpRequest でのプリフライトリクエスト無しのパターン

その他の API リクエストはこちらに該当します。また,CSRF 攻撃であるとして検知すべき副作用目的の API リクエストもこちらに該当します。プリフライトが発生しない条件の場合には同一オリジン制約を貫通してリクエスト自体は飛んできてしまうので,CSRF 対策によって「X-Requested-With ヘッダが無いため無効」または「Origin が許可対象ではないため無効」として扱わなければなりません。



  • Access-Control-Allow-Origin は,結果をフロントエンドで読み取れるかどうかだけに関わり,サーバサイドでアクションが実行されるかどうかには影響しません。


HTML フォーム送信のパターン

すべての HTML フォーム送信が該当します。CSRF 攻撃であるとして検知すべき副作用目的のフォームリクエストもこちらに該当します。常に同一オリジン制約を貫通してリクエスト自体は飛んできてしまうので,CSRF 対策によって「トークンが無効であるためリクエストも無効」または「Origin が許可対象ではないため無効」として扱わなければなりません。

レスポンスは JavaScript 上では読み取れませんが,レスポンスに含まれる Set-Cookie ヘッダがブラウザによって読み取られ,サードパーティクッキーの書き込みはできてしまいます。


⭐ CSRF 対策方法まとめ (サーバサイド)


Origin ヘッダの検証を採用する場合

この防御方法はプリフライトリクエストがどう処理されるかに依存しません。最低限すべきこととしては


  • メインリクエストにおける Origin ヘッダの検証

  • GET メソッドには副作用を持たせない

のみで構いません。サーバサイドの実装のみで済み,やることが非常に少ないので優れている方法だと言えます。(但し繰り返しますが,CSRF 対策をこれだけに委ねるのは辞めたほうが無難でしょう)


固有の HTTP ヘッダ (X-Requested-With) の検証を採用する場合

この防御方法はプリフライトリクエストが正しく処理されることに依存しているので,


  1. プリフライトリクエストに対し,必要に応じて適切な Access-Control-Allow-Origin を付与したレスポンスを返す。

  2. メインリクエストで X-Requested-With ヘッダの検証を行う。

という2段構えの防御が必要です。2番のステップはそもそもプリフライトが飛んでこないリクエストに対する防御手段であり,プリフライトが飛んでくるものに関しては必ず1番のステップで防がなければなりません。プリフライトリクエストにうっかり誤った応答をしてしまうと CSRF 脆弱性が発生するので注意しましょう。


トークンの検証を採用する場合

この防御方法はプリフライトリクエストがどう処理されるかに依存しません。手順は


  1. 推測不可能なトークンの事前発行

  2. メインリクエストにおける発行したトークンの検証

となります。