🗝️

CORSの仕様はなぜ複雑なのか

2021/07/31に公開

Webアプリケーションを実装していると高確率で CORS の問題にぶつかります。CORSがどのようなものかはリンクしたMDNなど既存の解説を読むのが手っ取り早いと思いますが、「なぜそのように設計されたのか」という観点での説明はあまり見ないため、昔の資料の記述や現在の仕様からの推測をもとに整理してみました。

CORSとは

現代のWebはドメイン名をもとにした オリジン (Origin) という概念 (RFC 6454) をもとに権限管理とアクセス制御を行っています。その基本となるのが以下のルールです。

上記Wikipedia記事によるとSOPの概念は1995年のNetscape 2.02に導入されたのが最初のようです。当時のドキュメンテーションを読む限り、これはウインドウ越しに別オリジンのDOMにアクセスするようなケースを想定していたようです。

CORS (Cross-Origin Resource Sharing) はSOPの例外ルールのひとつです。異なるオリジンへのHTTPリクエストが発生するさいに、相手側オリジンからの許可を得ることでアクセスを可能にするプロトコルです。

CORSの問題が最もよく見られるのはfetchXMLHttpRequest (XHR)などJavaScriptによるリソースの取得です。fetchのほうが現代的ですが、本稿では歴史的背景に言及するためにXMLHttpRequestを例に出します。

CORSが使われないとき

SOPが誕生したのが1995年、CORSが誕生したのが2005年2014年頃で、それ以前からWebは存在しています。

Webの仕様はできる限り既存の文書を壊さないように制定されていきます。SOPやCORSも例外ではなく、昔からあるクロスオリジンアクセスではCORSは使われません。たとえば、

  • <img src="...">
  • <script src="...">
  • <link rel="stylesheet" href="...">

などで発生するクロスオリジンアクセスは、 crossorigin 属性をつけない限りSOP/CORSの制約は発生しません。[1]

これらのリソースがCORSを経由せずに取得された場合は汚染フラグ (taint flag) が立てられ、スクリプトから利用したときに一定の制限が課されるようです。たとえば、

  • 当該 <script> が例外を発生させたときに window.onerror が受け取るエラー情報が制限されます。
  • 当該 <img> の画像を <canvas> に描画するとcanvasにも汚染フラグが立てられ、画像データの取得ができなくなります。

またHTTP Formも新しい技術だったようですが、それでも1995年よりは前です。おそらくこうした理由から、HTTP FormにもSOP/CORSは適用されていません。

CSRF

HTTP FormがSOPに従わないため、素朴に実装されたフォームは CSRF (cross-site request forgery) 脆弱性に晒されることになります。[2]

CSRFでは、悪意あるサイトに設置されたクロスオリジンのリンクやFormを利用者がクリックすることによって、利用者が持つ被害サイト上の認証情報を用いた操作を悪意ある第三者が代理で行使できてしまいます。

これを防ぐために、以下のような対策が行われます。

  • 操作にGETを使わない。
  • 第三者がクロスオリジンのリクエストを送れないように、以下のようなチェックを行う。
    • フォームに秘密情報を埋め込んでおいて、cookieに入れておいた秘密情報と照合する。 (CSRFトークン)
    • または、Refererをチェックする。

JSONP

やや本題から逸れますが、過去にJSONPが流行ったのもSOP/CORSと関係があります。

Wikipediaの記述によれば、Internet ExplorerのXMLHttpRequestにCORSが導入されたのはバージョン10以降です。それまではIEでクロスオリジンのリソースをJavaScriptから取得する方法は確立されていなかったことになります。

すでに述べたように <script> タグはデフォルトでSOP/CORSの対象外です。そこで、クロスオリジンでSOP/CORS対象外の <script> タグを発行することでXMLHttpRequestの代替としたのがJSONPです。

JSONPは相手側オリジン側に悪意があれば何でもできてしまうなど別のセキュリティー上の懸念もあり、クロスオリジンでXHRを行える環境が普及したことで代替策としての役割を終え使われなくなったようです。

素朴な仕様

CORSの仕様を読み解く前に、ここまでの前提をもとにした素朴な仕様を考えてみます。それは以下のようなものです。

  • プロトコル1
    • クロスオリジンのリクエストの場合、ブラウザはリクエストに Origin ヘッダを付与する。
    • サーバーは Origin ヘッダを見てアクセス制御を行い、必要に応じて403 Forbiddenを返す。

この「プロトコル1」で、CORSの目的である「相手側オリジンの許可を取る」自体は達成できるはずです。しかし、これにはいくつかの問題があります。

既存のセキュリティーレベルの維持

CORS - W3C Wikiに設計の前提が説明されています。

Origin ヘッダだけでは不十分であることを最も明確に説明しているのは以下の項目でしょう。

Must not require content authors or site maintainers to implement new or additional security protections to preserve their existing level of security protection.

抄訳:

コンテンツの作者やWebサイトの管理者が今までと同じセキュリティー水準を保つために、新規のまたは追加のセキュリティ保護を実装する必要があってはならない。

先ほど考えた「プロトコル1」はこの条件を満たしていません。CORSの登場以前はクロスオリジンのXHR自体が行えない仕様ですから、別オリジンからJSONのリクエストが飛んでくる可能性をサーバー側で考える必要はありませんでした。そのようなサーバーは通常未知のヘッダー (ここでは Origin) を単に無視するので、セキュリティー上の前提が変わってしまいます。基本的には、 Origin ヘッダを見かけたら403を返すように設定やコードを書き換える必要が出てきてしまいます。

そこで、上記の条件を達成するために、CORSをオプトインにして、クライアント側でもCORSのチェックを行うことにします。

  • プロトコル2
    • クロスオリジンのリクエストの場合、ブラウザはリクエストに Origin ヘッダを付与する。
    • サーバーは Origin ヘッダを見てアクセス制御を行い、必要に応じて403 Forbiddenを返す。
    • サーバーは Access-Control-Allow-Origin ヘッダを返し、CORSが有効であることを伝える。
    • ブラウザは Access-Control-Allow-Origin を見て、CORSの条件を満たさなかったらリクエストを却下する。

これでCORSのオプトイン化は達成されましたが、まだ問題があります。

安全でない操作の抑止

先ほどの「プロトコル2」は以下の条件を満たしていません。

It should not be possible to perform cross-origin non-safe operations, i.e., HTTP operations except for GET, HEAD, and OPTIONS, without an authorization check being performed.

抄訳:

クロスオリジンの安全でない操作を実行することが可能であってはいけない。つまり、 GET, HEAD, OPTIONS 以外のHTTP操作を認可チェック実行前に行ってはいけない。

先ほどの仕様ではJavaScriptがレスポンスを覗くのを防ぐことはできていますが、結局操作自体は実行されてしまっています。これでは結局 Access-Control-Allow-Origin の導入むなしく既存サーバーに対するCSRFの余地が残ってしまいます。

これを防ぐにはサーバーにリクエストする前にクライアント側で認可チェックを行う必要がありますが、認可チェックにはサーバーからの情報が必要です。そこで仕方なくリクエストを2回行うことにします。それが本番リクエストの前に行うpreflightリクエストです。

  • プロトコル3
    • preflightリクエスト
      • クロスオリジンのリクエストの場合、ブラウザは本番リクエストの前に、当該サーバーにOPTIONS methodでリクエストを投げる。このリクエストには Origin ヘッダやパスなど認可チェックに必要な情報が含まれる。
      • サーバーは Access-Control-Allow-Origin ヘッダを返し、CORSが有効であることを伝える。
      • ブラウザは Access-Control-Allow-Origin を見て、CORSの条件を満たさなかったら本番リクエストを行わずにリクエストを却下する。
    • 本番リクエスト
      • ブラウザはリクエストに Origin ヘッダを付与する。
      • サーバーは Origin ヘッダを見てアクセス制御を行い、必要に応じて403 Forbiddenを返す。

preflightが不要なケース

preflightリクエストには例外が存在します。その理由をはっきり述べた資料を見つけられていないのですが、ここでは推測で説明します。

preflightリクエストは明らかにRTT1回分の余計なレイテンシがかかります。CORSのレスポンス (Access-Control-*) はキャッシュ可能なのでうまくキャッシュが効けば次回以降のpreflightリクエストは不要になります。それでもできればRTTは削減したいものです。

ここでCSRF対策のことを思い出します。リンクやHTTP formを使うと (ユーザーの操作は必要ですが) CORSなしでクロスオリジンのリクエストを実行することができるため、これらのリクエストは元々サーバー側での対策が必要でした。

そのため、「リンクやHTTP formを使うことでもともと可能だったクロスオリジンのリクエスト」はすでにセキュリティ対策が行われており、CORSによって新しくもたらされる脅威ではありません。CORSではこのような種類のリクエストに "simple request" という名前をつけて定義を与えています。

A simple cross-origin request has been defined as congruent with those which may be generated by currently deployed user agents that do not conform to this specification.

https://www.w3.org/TR/2014/REC-cors-20140116/

実行しようとしたクロスオリジンリクエストが simple request に該当する場合は、すでにCSRF対策がなされていると仮定して「プロトコル2」で説明した手順が用いられます。

Access-Control ヘッダ

CORSへのオプトインを表明するために Access-Control-Allow-Origin ヘッダを使うことはここまでに説明しました。それに加えて Access-Control-Allow-Origin にはクライアント側の認可チェックのための情報が含まれており、同様の役割を持つ以下のようなヘッダも存在します。

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Credentials
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers

ここまでの議論を踏まえると、これらのヘッダはなくてもよいはずです。 (Access-Control-Allow-Origin でオプトインした上で、 Origin リクエストヘッダを元にサーバーサイドで制御すればいいため)

それでもこれらのヘッダが存在するのは、以下の要件によるものではないかと考えられます。

Must be able to deploy support for cross-origin GET requests without having to use server-side scripting (such as PHP, ASP, or CGI) on IIS and Apache.

抄訳:

サーバーサイドスクリプティング (PHP, ASP, CGI等) を用いずに、クロスオリジンの GET リクエストのサポートをIISやApache上にデプロイできなければならない。

今ならS3と言ったほうがわかりやすいかもしれませんが、必ずしもサーバーサイドの実装に手を加えなくてもCORSの設定が可能であることを重視していると考えられます。さらに、これは単にCORSを許可できるということではなく、許可/不許可の制御を一定粒度でできるところまでを意図しているのではないかと思います。

クレデンシャルと Access-Control-Allow-Origin: *

CORSリクエストはリクエスト時の設定で以下の2種類に分けられます。

  • 匿名リクエスト
    • 相手側オリジンに紐付いたクッキー等の情報は送られない。
  • クレデンシャルを用いたリクエスト
    • 相手側オリジンに紐付いたクッキー等の情報が送られる。

クレデンシャルを用いたリクエストは Access-Control-Allow-Credentials ヘッダでオプトインしたときだけ可能なようになっています。

追加の条件として、クレデンシャルを用いたリクエストでは Access-Control-Allow-Origin: * を使えないという制約が課されています。これはおそらくセキュリティー上の間違いを防ぐためのものでしょう。少なくとも、静的アセットを配布するユースケースとは異なり、クレデンシャルを用いたリクエストは通常動的なスクリプトによって処理されるはずです。そのため、設定の容易さのために Access-Control-Allow-Origin: * を許可する必要はないということになります。

まとめ

  • 本記事ではCORSが設計された背景をもとに、CORSの納得できる説明を試みるものである。
  • CORSはSOP (同一生成元ポリシー) の例外として、相手側オリジンの許可を得ることでリクエストを可能にするプロトコルである。
  • 歴史的経緯によりCORSを使わずにクロスオリジンリクエストを実行できる仕組みもいくつかあり、これらは汚染フラグによって一定のセキュリティーが担保されている。クロスオリジンフォームは特殊で、昔からサーバー側でCSRF攻撃への対策が必要だった。
  • 既存サーバーのセキュリティーを下げないこと、安全でないメソッドによるリクエストも可能にすること、静的サイトへのデプロイを容易にすることなどを目的に、サーバー側だけでなくクライアント側でも認可チェックを行う仕組みになっている。そのためリクエストを2回に分けて行う必要がある (preflight request)。
  • 昔ながらのセキュリティー対策が必要なリクエスト (simple request) に関しては安全性が元々担保されていると仮定し、preflight requestを行わないようになっている。
脚注
  1. 比較的最近の要素である <video><audio> もSOP/CORSがオプトインなのは謎です。 ↩︎

  2. IPAのCSRFの説明を読む限りでは、クロスオリジンではない種類のCSRF脆弱性もあるようですが、本稿ではクロスオリジンの場合のみを考えます。 ↩︎

Discussion