CORS
Cross-Origin Resource Sharing ・ こーす
別オリジンの応答を JS に読ませてよいかを、サーバの宣言をもとにブラウザが判定する仕組み。
✦ 30秒で試す — 航海ほかのサイトのデータを、ページのJSは勝手に読めない罠ページの JS が送った GET は、API サーバでどうなる?予想してみる概要
CORS(Cross-Origin Resource Sharing、オリジン間リソース共有)は、ブラウザが課している「別のオリジンの応答を、ページの JavaScript に読ませない」という制限を、サーバ側の宣言によって選択的に緩めるための仕組みです。オリジンとは「スキーム + ホスト + ポート番号」の組のことで、https://app.example.com と https://api.example.com はホストが違うので別オリジン、http://localhost:3000 と http://localhost:8080 もポートが違うので別オリジンです。
フロントエンドを localhost:3000 で、API を別のポートやドメインで動かした瞬間に、コンソールに赤い「CORS エラー」が出る。Web 開発者なら誰もが一度はぶつかる壁の正体がこれです。このエラーはブラウザが出しているもので、サーバは正常に応答していることが多いため、仕組みを知らないと「どこが悪いのか」の切り分けで長く迷います。
なぜ生まれたか
前提にあるのは、ブラウザの大原則「同一オリジンポリシー」です。1995年に Netscape Navigator が JavaScript を載せたとき、あるページのスクリプトが別のサイトの中身を覗けないように導入されました。もしこの制限がなければ、罠サイトのスクリプトがあなたのブラウザから銀行の API を呼び、ブラウザが自動で付ける Cookie(認証済みの証)付きのリクエストで、ログイン中の画面の中身を読み取れてしまいます。ブラウザは複数のサイトの認証情報を同時に抱えているので、「あるオリジンのスクリプトは、別のオリジンの応答を読めない」という壁が Web のセキュリティの土台になっています。
ところが Web アプリの形が変わり、SPA のフロントエンドと REST API のバックエンドを別オリジンで動かす構成が当たり前になると、この壁が正当な通信まで妨げるようになりました。2000年代には、<script> タグで読み込むものは制限を受けないことを利用した JSONP という回避策が広く使われましたが、読み込んだ先のコードを丸ごと自分のページで実行するので、相手が乗っ取られれば自分のページも乗っ取られる危うい方法でした。そこで「応答を提供するサーバ自身が、どのオリジンに読ませてよいかを HTTP ヘッダで宣言する」正式な緩和手段として CORS が設計されました。2000年代後半に各ブラウザが実装し、2014年に W3C 勧告となり、現在は WHATWG の Fetch Standard の一部として定義されています。
詳細
3つの登場人物: ページの JS・ブラウザ・API サーバ
CORS を理解するコツは、ページの JavaScript とブラウザを別の参加者として分けて考えることです。JavaScript が fetch を呼ぶと、実際にリクエストを組み立てて送るのはブラウザです。ブラウザは別オリジン宛てのリクエストに Origin: https://app.example というヘッダを自動で付けます。サーバは応答に Access-Control-Allow-Origin: https://app.example(または *)という「札」を付けて返します。ブラウザは応答を受け取ったあと、札と Origin を比べ、一致すれば JavaScript に渡し、一致しなければ応答を捨てて JavaScript には CORS エラーだけを返します。
ここから2つの大事な性質が出てきます。第一に、判定して遮断しているのはブラウザです。サーバ同士の通信や curl には CORS は一切関係なく、サーバの前に置く防御ではありません。第二に、次に説明する「単純なリクエスト」では、リクエストはサーバに届いて処理されており、止められるのは「応答を JS が読むこと」だけです。CORS はサーバを守る仕組みではなく、利用者の認証情報が別サイトに悪用されて情報が漏れるのを防ぐ仕組みだと理解するのが正確です。
単純なリクエストとプリフライト
ブラウザは、リクエストを2種類に分けて扱います。GET・HEAD・POST のいずれかで、Content-Type が application/x-www-form-urlencoded・multipart/form-data・text/plain のどれかで、独自のヘッダを付けていないものを「単純なリクエスト」と呼びます。これは CORS 以前から <form> や <img> で別サイトへ送れた形なので、いまさら止めても安全は増えません。ブラウザはそのまま送り、応答の札だけを確かめます。
それ以外、たとえば Content-Type: application/json の POST、PUT や DELETE、Authorization や X-Requested-With などの独自ヘッダ付きのリクエストは、昔のフォームでは送れなかった形です。サーバがそれを受ける前提で作られていないかもしれないので、ブラウザは本番の前に OPTIONS メソッドで「これから app.example から、この形で送ってよいか」を問い合わせます。これがプリフライト(先行便)です。サーバが Access-Control-Allow-Origin・Access-Control-Allow-Methods・Access-Control-Allow-Headers で承認して初めて、本番のリクエストが送られます。
往復が2回になるのがプリフライトの代償で、Access-Control-Max-Age で結果をしばらくキャッシュさせるのが定番です。「API を呼ぶと謎の OPTIONS が飛んでいる」「OPTIONS に 404 や 401 を返していて動かない」というのは、プリフライトを知らないときの典型的なつまずきです。OPTIONS には認証を要求せずに応答できるようにしておきます。
下の部品では、ページのリクエストをつかんで API サーバへ投げます。サーバのログに「処理済み」と出ているのに JS が読めない場面、JSON の POST で先行便だけが飛んで本番が止まる場面、サーバの設定を誤ると罠ページが読めてしまう場面を確かめてください。
罠ページ(evil.example)の GET /me をつかんで、右の API サーバへ投げてください。利用者の Cookie 付きで送られます。タップするとおまかせで送ります。
認証情報付きのリクエストと設定の落とし穴
Cookie や Authorization ヘッダを伴う別オリジンへのリクエストには、さらに厳しい決まりがあります。ページ側が fetch(url, { credentials: "include" }) と指定し、サーバ側が Access-Control-Allow-Credentials: true を返し、かつ Access-Control-Allow-Origin にワイルドカード * ではなく具体的なオリジンを書かなければ、ブラウザは応答を JS に渡しません。「* にしたのに Cookie 付きだと動かない」のはこのためです。
危険なのは、リクエストの Origin ヘッダの値をそのまま Allow-Origin に写し(オウム返し)、しかも Allow-Credentials: true を返す設定です。これは「世界中のどのサイトからでも、利用者のセッションの権限で API の応答を読んでよい」と宣言したのと同じで、同一オリジンポリシーの保護を自分で捨てています。前方一致や部分一致での照合(app.example を含むかどうか、など)も、app.example.evil.test のような名前で突破されます。許可するオリジンは完全一致の明示リストで持ち、オウム返しする場合は必ずリストと照合してからにします。応答がオリジンごとに変わるので、キャッシュが混ざらないよう Vary: Origin も付けます。
もう一つの落とし穴が null オリジンの許可です。ローカルのファイル(file://)や、sandbox 属性付きの iframe の中から送られたリクエストでは、Origin ヘッダが null という文字列になります。開発中に「null から来るので許可しておこう」と Access-Control-Allow-Origin: null を返すと、攻撃者も sandbox 付きの iframe を自分のページに置くだけで null オリジンを名乗れるので、誰でも読める設定になってしまいます。
CORS は CSRF 対策か
「CORS を正しく設定すれば CSRF は防げる」という説明を見かけますが、これは半分だけ正しい表現です。フォーム形式の POST のような単純なリクエストは、CORS の許可がなくてもサーバに届いて処理されます。CSRF は結果を読めなくても送金が通れば目的を果たすので、この範囲では CORS は防御になりません。一方、JSON の POST や独自ヘッダ付きのリクエストはプリフライトの対象なので、サーバが許可しなければ本番は送られず、結果として CSRF も起きません。「Content-Type が JSON のときだけ受け付ける」「独自ヘッダを必須にする」API が CSRF に強いのはこのためですが、フォーム形式も受け付けてしまう実装だと抜け道が残ります。状態を変える操作には、SameSite属性の Cookie と、CSRFトークンや Origin ヘッダの検証を別に用意するのが確実です。
実務での位置づけ
開発環境では、フロントエンドの開発サーバにプロキシを設定して API を同一オリジンに見せる方法と、API 側で開発用のオリジンを許可する方法がよく使われます。本番ではリバースプロキシで /api を同じオリジンにまとめてしまい、CORS そのものを不要にする構成も定番です。CORS の設定に迷ったら、「どのオリジンのページに、利用者の権限で応答を読ませたいのか」を明示のリストとして書き出すところから始めるとよいでしょう。
試問
利用者は api.example にログインしたまま、正規のフロント(app.example)と罠ページ(evil.example)を開いています。ページのボタン(リクエスト)を API サーバへ送ると、ブラウザの関所(CORS)の判定と、サーバのログが見えます。API サーバの設定は「app.example だけ許可」で、切り替えもできます。
罠ページ(evil.example)の GET /me をつかんで、右の API サーバへ投げてください。利用者の Cookie 付きで送られます。タップするとおまかせで送ります。
