HTTP / WebSpec

CORS とプリフライトを「何を守るのか」から理解する

ブラウザの JavaScript から別のドメインの API を呼ぶと、CORS エラーが出ます。「サーバー側に Access-Control-Allow-Origin を付ければ直る」とは知っていても、なぜそんな仕組みがあるのか、何を守っているのかを説明しようとすると、意外と言葉に詰まります。この記事では、混同されやすい 3 つの仕組みを分けて、「何を守るためにあるのか」から整理します。

CORS ができるまでの経緯(JavaScript が別オリジンを呼べなかった時代のこと)は、JavaScript が別オリジンを呼べなかった時代から CORS ができるまで にまとめています。仕様の形に納得がいかないときは、こちらも読んでみてください。

先に結論:3 行で言うと

  1. ブラウザは、別のオリジンのデータを JS に見せない(ユーザーを守るため。同一オリジンポリシー)
  2. 見せてよいかは、データの持ち主(サーバー)がヘッダーで宣言する(CORS)
  3. 昔は送れなかった種類のリクエストは、先に確認してから送る(プリフライト)

この先に出てくる話(単純リクエストの条件、CSRF、curl には効かないことなど)は、すべてこの 3 行から「だから、こうなる」と導かれる内容です。迷ったら、この 3 行に戻ってください。

3 つの仕組みを分けて考える

「CORS」とひとまとめに呼ばれがちですが、実際には役割の違う 3 つの仕組みがあります。

仕組み役割守るもの
同一オリジンポリシー別のオリジンのレスポンスを、JS に読ませないユーザー(のブラウザにある情報)
CORSサーバーが「このオリジンなら読んでいい」と許可する(制限をゆるめるための仕組み)
プリフライト新しい種類のリクエストは、先に確認してから送る古いサーバー

守っているのは同一オリジンポリシーで、CORS はそれを安全にゆるめるための仕組みです。そしてプリフライトは、ゆるめたことで古いサーバーが危険にならないようにするための仕組みです。

オリジンとは

オリジンは「スキーム + ホスト + ポート」の組み合わせです。どれか 1 つでも違えば、別のオリジンになります。

ページ呼び出す先同じオリジン?
https://a.example.comhttps://a.example.com/api同じ
https://a.example.comhttps://api.example.com違う(ホストが違う。サブドメインでも別)
https://a.example.comhttp://a.example.com違う(スキームが違う)
http://localhost:3000http://localhost:8080違う(ポートが違う)

同一オリジンポリシー:何を守っているのか

登場人物は 3 人

CORS の話がわかりにくい一番の原因は、登場人物が 3 人いることです。

登場人物例立場
ユーザーのブラウザ社員の PC の ChromeCookie(ログイン状態)や社内ネットワークへの入口を持っている
JS を動かしているページ(読みたい側)罠サイト evil.example悪意があるかもしれない
データを持っているサーバー(読まれる側)社内の人事システム守られる側
たとえ話:社員証を下げた秘書

ブラウザを「あなたの社員証を首から下げた秘書」だと考えてみます。秘書は社員証(Cookie)を持っていて、社内(社内ネットワーク)にも入れます。そして、あなたが開いたいろいろなサイトの JS が、この秘書に指示を出せます。

罠サイトの JS が秘書に「社内の人事システムから給与データを持ってきて、私に見せて」と頼んだとします。秘書は社員証を持っているので、人事システムには入れてしまいます。でも、同一オリジンポリシーがあるので、持ってきたデータを罠サイトには見せません。

一方、攻撃者が自分で(curl などで)人事システムに行っても、社員証(Cookie)を持っていないし、社内にも入れないので、データは取れません。

つまり、同一オリジンポリシーが守っているのは、ユーザーのブラウザが持っているログイン状態や社内ネットワークへの入口を、他のサイトに悪用させないことです。

攻撃者の手段Origin を偽造できる?ユーザーの Cookie・社内ネットワークを使える?
自分のサーバーから curlできる使えない
罠サイトに来たユーザーのブラウザを使うできない(ブラウザが付ける)使える

どちらの手段でも、両方はそろいません。守りはこの性質の上に成り立っています。

CORS:データの持ち主が例外を許可する仕組み

同一オリジンポリシーのままでは、正当な用途(画面と API のホストが別、など)でも別のオリジンのデータを読めません。そこで、読まれる側のサーバーが「このオリジンなら読んでいい」とレスポンスのヘッダーで宣言できるようにしたのが CORS です。

Access-Control-Allow-Origin: https://portal.example.com
罠サイトは自分に許可を出せない

「サーバーが許可するだけで、セキュリティになるの?」と思うかもしれません。ポイントは、許可を出すのはデータを持っているサーバー(読まれる側)で、悪意を想定しているのは JS を動かしているページ(読みたい側)だということです。

人事システムが Access-Control-Allow-Origin: https://evil.example を返すことはありません。罠サイトが自分のサーバーで Access-Control-Allow-Origin: * を返しても、読めるようになるのは罠サイト自身のデータだけです。他人のデータを読む許可は、そのデータの持ち主しか出せません。

ですから、CORS は守りを強くする仕組みではなく、同一オリジンポリシーという守りに、データの持ち主が決めた相手にだけ安全に穴を開ける仕組みだと考えるのが正確です。逆に、来た Origin をそのまま許可して、さらに Access-Control-Allow-Credentials: true を付けるような設定は、どのサイトからでもログイン状態を使ってデータを読めてしまう、よくある脆弱性になります。

判断しているのはブラウザ

CORS の流れを、誰が何をしているかで分けると次のようになります。

① ブラウザ → サーバー:リクエスト(Origin: https://portal.example.com)
② サーバー → ブラウザ:レスポンス(データ + Access-Control-Allow-Origin)
③ ブラウザ:ヘッダーを見て、JS に見せるかどうかを判断する

サーバーは処理してレスポンスを返し、ヘッダーを付けているだけです。見せるかどうかを決めているのはブラウザです。そのため、ブラウザではない curl やサーバー間の通信には、CORS はまったく効きません。

判断する場所curl に効く?
IP 制限サーバー・ネットワーク側効く
認証(トークンなど)サーバー側効く
CORSブラウザ側効かない

CORS は API を守る仕組みではありません。 API の守りは、認証や IP 制限で行います。

プリフライト:古いサーバーを守る事前確認

単純リクエストと、それ以外

別のオリジンへのリクエストは、ブラウザによって 2 種類に分けられます。

種類条件(すべて満たす)ブラウザの動き
単純リクエストメソッドが GET / HEAD / POST、独自ヘッダーなし、Content-Type が application/x-www-form-urlencoded・multipart/form-data・text/plain のどれかいきなり本番のリクエストを送る
それ以外上の条件を 1 つでも外れる先に OPTIONS(プリフライト)を送って確認する

Content-Type: application/json で送る、Authorization ヘッダーを付ける、PUT や DELETE を使う、のどれか 1 つでも当てはまるとプリフライトが発生します。今どきの API 呼び出しは、ほとんどがこちらです。

プリフライトのやりとり
// https://portal.example.com のページで実行
fetch("https://api.example.com/users", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": "Bearer xxx"
  },
  body: JSON.stringify({ name: "test" })
});

このコードを実行すると、JS には書いていない OPTIONS を、ブラウザが自動で先に送ります。

OPTIONS /users HTTP/1.1
Origin: https://portal.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

サーバーが許可を返すと、

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://portal.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

ブラウザは本番の POST を送ります。許可がなければ、本番のリクエストは送られません。

「届くけど読めない」と「そもそも送られない」

ここで意外なのが、単純リクエストの場合です。

リクエストCORS 未対応のサーバーに送ると
単純リクエストリクエストは届いて処理される。JS がレスポンスを読めないだけ
プリフライトが必要なリクエストOPTIONS で止まり、本番のリクエストは送られない

つまり、単純リクエストの形式の POST なら、CORS エラーが出ていても、サーバー側では更新が実行されていることがあります。これが CSRF の原因になります。CSRF の手口と対策は、CSRF とは何か:別のサイトから「勝手に送らせる」攻撃と対策 にまとめています。

なぜ単純リクエストにはプリフライトがないのか

中途半端な仕様に見えますが、理由があります。CORS ができるずっと前から、HTML だけで別のオリジンにリクエストを送れたからです。

<!-- 別オリジンへの POST(JS がなくても、昔から送れる) -->
<form action="https://api.example.com/orders" method="POST">
  <input name="id" value="1">
</form>

<!-- 別オリジンへの GET -->
<img src="https://api.example.com/orders?id=1">

これは単純リクエストの条件とほぼ同じです。fetch の単純リクエストにプリフライトを付けても、攻撃者は <form> を使えば同じリクエストを送れるので、守りになりません。そこで「昔から送れたものは、そのまま送る」と割り切りました。その代わり、こうしたリクエストへの対策(CSRF 対策)は、昔からサーバー側の責任です。

なぜプリフライトが古いサーバーを守るのか

一方、CORS ができる前は、JSON の POST、独自ヘッダー、PUT や DELETE を別のオリジンから送る方法はありませんでした。JS の通信(XMLHttpRequest)は、同じオリジンにしか送れなかったからです。

そのため当時のサーバーには、「こういうリクエストは、別のオリジンから来るはずがない」という前提で作られたものがありました。たとえば「X-Requested-With ヘッダーが付いていれば、自分のサイトの JS からなので CSRF ではない」と判断していたサーバーもあります。

もしプリフライトがなく、いきなり本番のリクエストを送る仕様だったら、どうなるでしょうか。

罠サイトの JS → 古いサーバー:DELETE /users/123(独自ヘッダー付き、Cookie 付き)
古いサーバー:「独自ヘッダーがあるから自分のサイトからだ」→ 削除を実行

レスポンスは読めなくても、削除はもう実行されています。新しいブラウザの機能のせいで、昔は安全だったサーバーが突然危険になるわけです。

プリフライトがあると、こうなります。

① ブラウザ → 古いサーバー:OPTIONS(evil.example から DELETE を送ってもいい?)
② 古いサーバー:CORS を知らないので、許可のヘッダーを返さない
③ ブラウザ:許可がない → 本番の DELETE は送らない

CORS を知らないサーバーは、許可のヘッダーを返すはずがありません。だから、古いサーバーは何もしなくても、自動で「拒否」になります。 CORS に対応すると決めたサーバーだけが、新しい種類のリクエストを受け付けます。これがプリフライトが古いサーバーを守る理由です。

この時代の事情は、JavaScript が別オリジンを呼べなかった時代から CORS ができるまで で詳しく書いています。

これから API を作るとき

プリフライトへの対応が必要なのは一部の API だけ
API の使われ方CORS・プリフライトへの対応
同じオリジンのページから呼ぶ不要(CORS が発生しない)
サーバーやバッチから呼ぶだけ不要(ブラウザが関係しない)
別のオリジンのページの JS から呼ぶ必要

ブラウザから呼ばせたくない API では、何もしないのが一番安全です。対応しなければ、ブラウザが自動で拒否してくれます。

対応するときに実装すること
  • OPTIONS に応答する(Access-Control-Allow-Origin は * ではなく具体的なオリジン、Allow-Methods、Allow-Headers、Max-Age)
  • OPTIONS は認証なしで通す(プリフライトには Authorization ヘッダーも Cookie も付かないため)。業務の処理は実行しない
  • 本番のレスポンスにも、エラーのレスポンスにも Access-Control-Allow-Origin を付ける(付けないと、401 などのエラーの内容を JS が読めない)
  • Vary: Origin を付ける
  • リバースプロキシとアプリの両方でヘッダーを付けない(2 重になるとブラウザに拒否される)

自分で書くより、フレームワークや API Gateway、リバースプロキシの CORS 機能を使うのが確実です。

作る側として気をつけること
  • 更新系の API は application/json だけを受け付けるようにする。form 形式を受け付けると、プリフライトなしで送られて処理されてしまう
  • 必要に応じて、サーバー側で Origin や Sec-Fetch-Site ヘッダーを確認し、許可していないサイトからの更新を拒否する(ブラウザの JS はこれらを偽造できないので、CSRF 対策として効果がある)
  • 許可するオリジンを広げすぎない。特に Access-Control-Allow-Credentials: true との組み合わせに注意する

まとめ

  • 同一オリジンポリシーは、ユーザーのブラウザが持つログイン状態や社内ネットワークへの入口を、他のサイトに悪用させないための基本の制限。
  • CORS は、データの持ち主(読まれる側のサーバー)が、決めた相手にだけ例外を許可する仕組み。判断するのはブラウザなので、curl には効かない。API の守りは認証で行う。
  • プリフライトは、昔はできなかった種類のリクエストを送る前に確認する仕組み。CORS を知らない古いサーバーは自動で拒否になる。単純リクエストにないのは、<form> で昔から送れたから。
理解の確認

次の質問に自分の言葉で答えられれば、人に説明できるレベルです。

  1. curl なら CORS エラーにならないのはなぜ?
  2. 攻撃者が Origin ヘッダーを偽造しても問題にならないのはなぜ?
  3. 単純リクエストにプリフライトがないのはなぜ?
  4. CORS を設定しても、API に認証が必要なのはなぜ?
  5. 社内にある認証なしの API が、CORS だけでは安全と言えないのはなぜ?
スポンサーリンク
タイトルとURLをコピーしました