CORSはCSRFの対策にならないという話(あるいはCORSは何でないか)


タイトルの通りです。条件付きでなることもあります。

CORSとは

  • https://developer.mozilla.org/ja/docs/Web/HTTP/CORS
  • Cross-Origin Resource Sharing
  • 異なるオリジンでリソースを共有できるか、共有できないかを決める仕組み
    • オリジン≒ドメインだと考えてよい(厳密には MDN あたりを参照)
  • この「共有」とは「JavaScriptから見て」であることに注意
    • この記事ではこの話を書きます
  • WebサーバーAは、Aの持つリソースをどのオリジンと共有するかをホワイトリスト形式で指定できる

CORSが正しく機能する具体例

読み込まれるリソースの設定

ローカルネットワーク上に http://example.local/secret.php があり、GETすれば秘密の情報が取得できるとします。
また、このサーバーのレスポンスには必ず

  • Access-Control-Allow-Origin: http://good.com
  • Access-Control-Allow-Credentials: true

というヘッダが付いているとしましょう。

許可されている場合

上記の http://example.local/secret.php を読み込むJavaScriptを書き、http://good.com でホストすると、ちゃんと値が取れます。

HTML+JavaScript
index.html
<!doctype html>
<html>
<body>
<input type="text" id="text" style="width: 30em;" />
</body>
<script>
const xhr = new XMLHttpRequest();
xhr.onreadystatechange = () => {
  if (xhr.readyState === 4) {
    document.getElementById("text").value = xhr.responseText;
  }
}
xhr.withCredentials = true;
xhr.open("GET", "http://example.local/secret.php", false);
xhr.send();
</script>
</html>

許可されていない場合

まったく同じJavaScriptを http://evil.com でホストすると、ちゃんと読み込みに失敗します。良かったですね。

何が起きているのか?

次のようなことが起きています。

  1. JavaScriptがXHRを生成する
  2. ブラウザがCookieを付けてGETリクエストを送信する
  3. Webサーバー example.localGETリクエストに応じてレスポンスを返す
  4. ブラウザがレスポンスのヘッダを見てCORSに適合するかどうかチェックする
  5. 適合すれば結果をJavaScriptに返し、そうでなければエラーを発生させる

evil.com においても、リクエスト自体は送信されてしまう(ついでに結果も返ってきている)がJavaScriptからはその値が分からないだけ、というのがポイントです。次に行きましょう。

CSRFが成功してしまう例

ある脆弱なSNS、 https://example.com があるとします。
このサイトではログインした状態で https://example.com/post.php にPOSTを送ると書き込みができます。

post.php
// Cookieの値を見てユーザー認証処理
...
// DBにテキストを書き込む
$db->writePost($userId, $text);

では次のシナリオを考えましょう。

  1. ユーザーが何かのきっかけで https://evil.com/index.html をブラウザで開く
  2. このページ上のJavaScriptがXHRを生成する
  3. ブラウザが https://example.com/post.phpCookieを付けてPOSTリクエストを送信する
  4. post.phpPOSTリクエストに応じて処理を行い、レスポンスを返す
  5. ブラウザがレスポンスのヘッダを見てCORSに適合するかどうかチェックする
  6. 適合しないのでエラーが発生する

このJavaScriptはCSRFを発生させることが目的なので、6で発生するエラーはどうでもいいですね。4のサーバー側処理で正しくCSRF対策を行っていなければアウトです。

CSRFがCORSで防げる例

とはいえ、CORSが完全にCSRFに対して無意味だとは限りません。CORSの仕様をよく読んでみると、プリフライトリクエストというものが出てきます。

シンプルなクロスオリジンリクエスト

「シンプル」なクロスオリジンリクエストとは、

  • GET, HEAD, POSTのいずれか
  • 特定のヘッダだけを持つ
  • その他いくつかの条件を満たす

という条件を全て満たすリクエストです。この場合、先ほどの例のようにいきなり対象のWebサーバーにリクエストが飛びます。

「シンプルではない」クロスオリジンリクエスト

上記の条件を満たさないリクエスト、たとえば

  • DELETEリクエスト
  • X-API-KEY ヘッダを持つリクエスト

などの場合がこれにあたります。JavaScriptが「シンプルではない」クロスオリジンリクエストを送信しようとすると、ブラウザはそれに先立ってOPTIONリクエストを送信します。

  • これをプリフライト(preflight)リクエストと呼びます。
  • このリクエストは強制的に発生します。この挙動はJavaScriptからは制御できません。

ブラウザはOPTIONリクエストのレスポンスで Access-Control-Allow-Origin ヘッダ等を検証し、ここで検証が失敗すればそれ以降のリクエストはキャンセルします。問題がなければ改めて本番のリクエストを送信します(つまり1回のXHRが2回のリクエストを発生させることになります)。

具体例

つまり、次のような仕組みを採用するならCORSでCSRFを防げるということになります。

  • 重要な操作を行う場合は X-CSRFToken のようなヘッダを付けてリクエスト送信を行うような仕様にする
  • このヘッダのチェックはサーバー側でも正しく行う
  • サーバーでは正しく Access-Control-Allow-Origin ヘッダを出力する

これならば、 evil.com 上のスクリプトは

  • X-CSRFToken ヘッダを付けてリクエストを送る
    • プリフライトリクエストが飛ぶのでCORSで拒否される
    • もし何かの間違いがあっても、正しいヘッダの値はWebサイト側でしか知りようがないため、サーバー側処理の検証で拒否される
  • X-CSRFToken ヘッダを付けずにリクエストを送る
    • サーバー側の検証で拒否される

と選択肢を潰されてしまうのでCSRF対策になります。ただし、リクエストを単純な <form> で済ませることはできなくなります。

まとめ・CORSは何ではないか

CORSはユーザーの意図しないリクエストを発生させることを防ぐためのものではなく、返ってきた値を邪悪なJavaScriptのコードが参照することを防ぐためのものです。リクエスト自体はいくらでも発生させることができるので、サーバー側の検証なしでCSRF対策とすることはできません。気をつけましょう。