CORS(Cross-Origin Resource Sharing)について整理してみた
ブラウザからAmazon S3に直接ファイルをアップロードしたい
先日、Amazon S3にファイルをアップロードするWebアプリを作ろうとして色々調べていたところ、S3にCORSという仕様のクロスドメインアクセスの設定をすることによって、ブラウザから直接S3にアップロードをする方法にたどり着きました。ただ、この方法を使うにあたってはCORSというクロスドメインアクセスの仕様をきちんと理解しておいた方が良さそうでしたので、まずはCORSについて自分なりに整理してみました。
なお、弊社の横田がCORSとS3についての記事を以前書いていますので、S3のCORSサポートに関する概要を知りたい方はそちらをご覧下さい。
CORS(Cross-Origin Resource Sharing)によるクロスドメイン通信の傾向と対策
CORS
ブラウザでAjax通信を行う際には、同一生成元ポリシー(Same Origin Policy)によってWebページを生成したドメイン以外へのHTTPリクエストができません。しかし、異なるドメインのリソースにアクセスしたいというニーズは常にあり、現在では比較的手軽であるJSONPなどの手法を利用して、同一生成元ポリシーの制約をかいくぐる形で実現されています。しかし、ユーザー情報に対するセキュリティを確保するために各ブラウザで実装されている同一生成元ポリシーをあえて無視する以上、悪意を持った第三者からの攻撃を受けるリスクが高くなる可能性があります。
こうした状況の中で、より手軽に、より安全にクロスドメインアクセスを実現したいという要求に応えるために作られたのがCORSです。CORSとはCross-Origin Resource Sharingの略で、XMLHttpRequestでクロスドメインアクセスを実現するための仕様です。現在W3Cで仕様が策定されており、2013年1月に勧告候補となりました。
CORSの仕様では、クロスドメインアクセスを行うクライアント(ブラウザ)側とクロスドメインアクセスされるサーバー側のふるまいが規定されています。その概要は、クロスドメインアクセスされるサーバー側でアクセスを制御するルールを設定し、ブラウザとサーバー側でHTTPヘッダを使ってアクセス制御に関する情報をやりとりしながらドメインをまたいだアクセスをするというものです。サーバー側で設定するアクセスを制御するルールは以下のようなものがあります。
- クロスドメインアクセスを許可するWebページのオリジンサーバーのドメイン
- 使用を許可するHTTPメソッド
- 使用を許可するHTTPヘッダ
CORSでは、ブラウザのXMLHttpRequestオブジェクトが重要な役割を果たし、それを利用するクライアントアプリ側ではCORSの仕様をあまり意識する必要がありません。一方、クロスドメインアクセスされるサーバー側では、CORSの仕様に則った実装を行う必要があります。
CORSを利用するにあたっての基礎知識
XMLHttpRequest
XMLHttpRequestは現在W3Cで機能拡張が検討されており、Editor's Draftのステータスとなっています。一時期は一部の機能がXMLHttpRequest Level2という名前で別のドラフトとして仕様策定されていましたが、現在はXMLHttpRequestにマージされて仕様策定がされています。このため、XHR2などと呼ばれていたりします。拡張される機能としては、ファイルアップロードやスクリプトでのフォームの作成と送信などがありますが、CORSの仕様に準じたクロスドメインアクセス機能もこの中に含まれています。CORSを利用するにはブラウザがXHR2をサポートしている必要がありますが、現在は一部のバージョンのIEを除いてほとんどのブラウザがすでに実装済みです。
preflightリクエスト
WebページなどのクライアントがCORSの仕様に準じてクロスドメインのリソースへアクセスする場合、通信手順は以下の2パターンのうちどちらかとなります。
- 直接クロスドメインのリソースにアクセスするリクエストを送信する
- クロスドメインアクセスが可能か確認するリクエストを送信し、そのレスポンスを受けた後に改めてクロスドメインのリソースアクセスを行う
1つ目のパターンは単にクロスドメインのリソースに対してHTTPリクエストを送って、レスポンスを受けているだけです。後述するCORSで必要となるリクエストヘッダが含まれていることを除けば、同一ドメインのリソースに対するアクセスと変わりません。シンプルなリクエストと呼ばれています。
2つ目のパターンにある「クロスドメインアクセスが可能か確認するリクエスト」は、preflightリクエストと呼ばれます。preflightリクエストでは、クライアントとリソースを提供するクロスドメインのサーバーが、HTTPヘッダを利用してお互いにクロスドメインアクセス制御に関する情報を事前にやりとりします。HTTPのOPTIONSメソッドが利用されます。
preflightリクエストが送信される条件
クライアントがクロスドメインアクセスを行う場合、preflightリクエストを送る必要があるかをブラウザが判断します。ブラウザがpreflightリクエストを送る必要があると判断した場合は、クロスドメインアクセスの実際のリクエスト送信に先立って、自動的にpreflightリクエストが送信されます。このため、クライアントアプリ側でpreflightリクエストを送るコードを書く必要はありません。
以下の条件の全てに該当する場合、ブラウザはpreflightリクエストを送る必要がないと判断し、シンプルなリクエストを送信します。それ以外の場合は、preflightリクエストを送信します。
- HTTPメソッドがGET, POST, HEADのいずれか
- HTTPヘッダにAccept, Accept-Language, Content-Language, Content-Type以外のフィールドが含まれない
- Content-Typeの値はapplication/x-www-form-urlencoded, multipart/form-data, text/plainのいずれか
HTTPヘッダに関しては、ブラウザのデフォルトヘッダはpreflightの判断基準にならないようです。
クレデンシャル
クロスドメインアクセスを行う際、デフォルトではCookieのやりとりができないようになっています。リクエスト送信の際にXMLHttpRequestオブジェクトのwithCredentialsプロパティをtrueに設定すると、リクエストにCookieが付加されるようになります。この場合、ブラウザは以下のヘッダを含まないレスポンスを拒否します。
Access-Control-Allow-Credentials: true
なお、クロスドメインのサーバーによってセットされたCookieはクライアントからはアクセスすることができません。あくまでクロスドメインのサーバーでCookieが必要な場合にのみ利用するようにします。
セキュリティ
CORSはあくまで同一ドメインポリシーの制約の中でクロスドメインアクセスを行うための枠組みにしか過ぎません。これとセキュリティは別の問題です。CORSを利用してもXSSやCSRFの危険は依然としてありますので、従来通り対策を施す必要があります。また、サーバー側はリソースを守るために何らかの認証の仕組みを入れるべきです。ただし、CORSではJSONPのようにサーバーに任意のスクリプトを実行させる機会を与えるような事はありません。
実際にCORSでクロスドメインアクセスを行ってみる
仕様だけ追いかけていてもいまいち分かりづらいので、実際にCORSでクロスドメインアクセスを行うサンプルを作ってみました。実際のリクエストとレスポンスのヘッダの情報を見ながら、CORSによるクロスドメインアクセスの流れを把握していきたいと思います。
サンプルアプリのソースコードはGitHubに公開しました。サーバ側アプリはScalatra、クライアント側アプリはjQuery + knockout + TypeScriptで作成していますが、簡単な実装しかしていませんので、どの言語やフレームワークを使う方でも処理の概要はつかめるかと思います。
以下、開発環境です。
- OSX 10.8 Mountain Lion
- Google Chrome 25
- Scala 2.9.2
- sbt 0.12.2
- TypeScript 0.8.3
異なるドメインでサーバーを起動する
今回は、クライアントのホストはhttp://localhost:8080/で、サーバー側アプリはhttp://localhost:8081/で動作させました。
なお、ブラウザがクロスドメインアクセスであると判定する条件は、以下の3点のうちいずれかに該当する場合です。
- プロトコルが異なる(httpとhttpsも異なると判定される)
- ホスト名が異なる
- (ポート番号が明示されている場合)ポート番号が異なる
今回のサンプルではポート番号が異なるため、ブラウザはクライアントからREST APIを公開するサーバーへのアクセスをクロスドメインであると判断します。
サーバー側アプリの実装
サーバー側は、簡単なREST APIを公開するだけのものをCORS対応させています。ScalatraにはCORSをサポートするヘルパートレイトであるCorsSupportが用意されていますので、そちらを利用してCORSに対応させています。
以下は、コントローラのソースコードです。JSONデータを返すGETのオペレーションと、JSONデータを受け取って格納するPOSTのオペレーションの2つを用意しています。
package jp.classmethod.scalatracorsserver import org.scalatra._ import org.scalatra.json._ import org.json4s._ class CorsController extends ScalatraServlet with JacksonJsonSupport with CorsSupport { override protected implicit val jsonFormats = DefaultFormats private[this] val postRepo = new PostRepository before() { contentType = formats("json") } get("/") { params.getAs[String]("name") match { case Some(name) => postRepo getPostsByName name case None => postRepo.allPosts } } post("/") { parsedBody.extractOpt[Post] match { case Some(post) => postRepo addPost post Ok() case _ => halt(400, "invalid params") } } notFound { halt(404) } }
クライアント側アプリの実装
クライアントは上記サーバーのREST APIを単純に叩くだけのアプリです。以下は、クライアント側でAPIに対してリクエストを送信する部分のソースコードの抜粋です。
GETメソッドのオペレーションにリクエストを送信
var sendData = null if (name && name.length > 0) { sendData = { "name": name } } $.getJSON(this.url, sendData) .done((data) => resultHandler(data)) .fail((jqHXR, textStatus, errorThrown) => console.log("getPosts failed. " + textStatus + errorThrown))
POSTメソッドのオペレーションにリクエストを送信
var sendData = { "name" : name , "comment" : comment } $.ajax({ type: "POST", url: this.url, data: JSON.stringify(sendData), contentType: "application/json", dataType: "text" }) .done((data) => resultHandler()) .fail((jqHXR, textStatus, errorThrown) => console.log("addPost failed. " + textStatus + errorThrown))
シンプルなリクエストでアクセスするパターン
ではまず、シンプルなリクエストによるクロスドメインアクセスを行う場合を見てみます。上記サンプルでは、GETでリソースにアクセスした場合が該当します。以下は、実行した際のリクエストヘッダとレスポンスヘッダの内容です。
リクエストヘッダ
GET /posts HTTP/1.1 Host: localhost:8081 Connection: keep-alive Cache-Control: max-age=0 Accept: application/json, text/javascript, */*; q=0.01 Origin: http://localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.160 Safari/537.22 Referer: http://localhost:8080/ Accept-Encoding: gzip,deflate,sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.3
レスポンスヘッダ
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://localhost:8080 Content-Type: application/json;charset=UTF-8 Content-Length: 2 Server: Jetty(8.1.8.v20121106)
サーバー側でのリクエストヘッダのチェック
Originフィールドのチェック
サンプルのリクエストヘッダは"Origin"というフィールドがあり、クライアントのオリジンサーバのドメインがセットされています。全てのCORSによるリクエストヘッダには、このフィールドが付加されている必要があります。オリジンサーバのドメインとXHRによるアクセス先のドメインが異なる場合は、ブラウザ側で自動的にOriginフィールドを付加してくれます。
CORS対応をしているサーバーがリクエストをクロスドメインから受けた際には、まずCORSのリクエストであるかを判断します。判断基準は下記の通りです。
- リクエストヘッダにOriginフィールドの値が設定されているか
サンプルのリクエストヘッダは"Origin"というフィールドがありますので、CORSのリクエストであると判断されます。さらに、このOriginフィールドのドメインがクロスドメインアクセスを許可する対象であるかを判定し、受け入れの判断をします。
実際のリクエストとpeflightリクエストの切り分け
次に、サーバーはpreflightリクエストであるかどうかを判断します。この際のチェックポイントは、以下の2点です。
- HTTPメソッドがOPTIONSであるか
- リクエストヘッダにAccess-Control-Request-Methodフィールドが付加されているか
上記2点に該当する場合、preflightリクエストを受け取ったとして処理することになります。サンプルのリクエストはGETメソッドですので、該当しません。
アクセス許可のチェック
続いて、以下のアクセス許可をチェックします。
- HTTPメソッドはアクセスを許可しているものであるか
このサンプルのサーバーではGET, POST, OPTIONSを許可しているので、HTTPメソッドは問題ありません。ここまでチェックし終えると、リクエストに対してリソースのアクセスを許可するという判断となります。
レスポンスヘッダの作成
Access-Control-Allow-Origin
CORSのリクエストが成立した場合、リクエストがアクセスを求めてきたリソースをレスポンスで返す際に、必ずレスポンスヘッダのAccess-Control-Allow-Originフィールドにクロスドメインアクセスが許可されるサイトのドメイン名を付加します。レスポンスヘッダにこのフィールドがない場合、ブラウザはクロスドメインアクセスに失敗したと判断してエラーを発生させます。サンプルのレスポンスヘッダにはきちんとこのフィールドが付加されています。
その他のレスポンスヘッダフィールド
また、場合によってはいくつかCORSのレスポンスヘッダを付加する事になります。まず、CORSによるリクエストがサーバーにおいてクライアントに対して使用可能なリクエストヘッダを公開するよう設定されている場合、Access-Control-Expose-Headersフィールドに使用可能なリクエストヘッダのホワイトリストを付加します。もう一点、リクエストにCookieの情報が付加されており、なおかつサーバー側の設定でCookieの利用が許可されている場合はレスポンスヘッダのAccess-Control-Allow-Credentialsフィールドにtrueをセットします。
preflightリクエストありでアクセスするパターン(preflightリクエスト)
次に、preflightリクエストを行うパターンを見てみましょう。サンプルアプリでPOSTメソッドでリソースにアクセスすると、preflightリクエストを利用したクロスドメインアクセスを行います。
preflight時のヘッダ
以下は、サンプルアプリにおけるpreflight時のリクエストヘッダとレスポンスヘッダです。
preflightリクエストのヘッダ
OPTIONS /posts HTTP/1.1 Host: localhost:8081 Connection: keep-alive Access-Control-Request-Method: POST Origin: http://localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.160 Safari/537.22 Access-Control-Request-Headers: accept, origin, content-type Accept: */* Referer: http://localhost:8080/ Accept-Encoding: gzip,deflate,sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.3
preflightレスポンスのヘッダ
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://localhost:8080 Access-Control-Max-Age: 10 Access-Control-Allow-Methods: GET,POST,OPTIONS Access-Control-Allow-Headers: Content-Type Transfer-Encoding: chunked Server: Jetty(8.1.8.v20121106)
クライアント側でのpreflightリクエストの発生
先程説明した通り、ブラウザがpreflightリクエストを発生させるには条件があります。クライアント側からリクエストを送る部分のソースコードをもう一度確認してみます。
$.ajax({ type: "POST", url: this.url, data: JSON.stringify(sendData), contentType: "application/json", dataType: "text" })
このリクエストのHTTPメソッドはPOSTですが、Content-Typeにapplication/jsonが指定されています。Content-Typeの値がapplication/x-www-form-urlencoded, multipart/form-data, text/plainのいずれでもないため、クロスドメインアクセスをしようとするとpreflightリクエストが発生することになります。
サーバー側でのリクエストヘッダのチェック
Originフィールドのチェック
preflightリクエストのヘッダの内容を見ると、こちらにもやはりOriginフィールドが付加されているのが確認できます。また、"Access-Control-Request-Method"と"Access-Control-Request-Headers"という2つのフィールドが含まれています。
サーバー側でこのリクエストを受けた場合、まず先ほどのpreflightリクエストなしでのクロスドメインアクセス時同様に、Originフィールドが妥当であるかチェックする必要があります。Originが許可されていないドメインだった場合、preflightリクエストは失敗したとして処理します。Originが許可されているものであった場合、HTTPメソッドがOPTIONSであることとAccess-Control-Request-Methodフィールドが付加されている事を確認します。これらの条件が満たされているリクエストはpreflightリクエストであるとみなします。
Access-Control-Request-Method
Access-Control-Request-Methodフィールドは、preflightリクエスト後の実際のリクエストで使われるメソッドの情報をサーバー側に提供するためのヘッダフィールドです。このフィールドで指定されているメソッドによるアクセスをサーバーが許可していない場合、preflightリクエストは失敗となりリソースへのアクセスを拒否するよう処理します。
このヘッダは、Originフィールドと同様にpreflightリクエスト送信時にブラウザによって自動的に付加されます。
Access-Control-Request-Headers
Access-Control-Request-Headersフィールドも同じように、preflightリクエスト後の実際のリクエストで使われるリクエストヘッダの情報をサーバー側に提供するためのヘッダフィールドです。このフィールドで指定されたリクエストヘッダをサーバーが許可していない場合には、やはりpreflightリクエストは失敗となり、アクセスを拒否します。
このヘッダも、Originフィールドと同様にpreflightリクエスト送信時にブラウザによって自動的に付加されます。
レスポンスヘッダの作成
ここまでのチェックが問題ない場合、正しいpreflightリクエストであると判断して正常なレスポンスを返す事になります。
preflightのレスポンスにも必ずAccess-Control-Allow-Originフィールドにクロスドメインアクセスを許可するドメイン名の情報をセットします。また、preflightのレスポンスには必ずAccess-Control-Allow-MethodsフィールドとAccess-Control-Allow-Headersフィールドを付加する必要があります。
Access-Control-Allow-Methods
Access-Control-Allow-Methodsフィールドは、preflight後の実際のリクエストで利用を許可するHTTPメソッドの情報をクライアント側に提供するためのヘッダフィールドです。ちょうど、リクエストの際のAccess-Control-Request-Methodフィールドの対になっています。
Access-Control-Allow-Headers
Access-Control-Allow-Headersフィールドは、preflight後の実際のリクエストで利用を許可するリクエストヘッダの情報をクライアント側に提供するためのヘッダフィールドです。こちらもちょうど、リクエストの際のAccess-Control-Request-Headersフィールドの対になっています。
Access-Control-Max-Age
クロスドメインアクセスの度にpreflightリクエストを送信するのでは、クライアント・サーバーともにリクエストの送受信のコストが無駄にかかってしまいます。Access-Control-Max-Ageフィールドは、preflightの結果を指定した時間キャッシュすることをブラウザに許可するためのヘッダフィールドです。値の単位は秒です。このフィールドで指定した時間が経過するまでの同一リソースに対するアクセスに関しては、ブラウザはpreflightリクエストを発行せずに実際のリクエストを直接送ります。このフィールドをレスポンスに含めるかは任意です。
preflightリクエストありでアクセスするパターン(実際のリクエスト)
実際のリクエスト時のヘッダ
preflightリクエスト後の実際のアクセスにおけるリクエスト・レスポンスの内容は、基本的にはシンプルなリクエスト時と同じになります。以下は実行時のヘッダの内容です。
実際のリクエストのヘッダ
POST /posts HTTP/1.1 Host: localhost:8081 Connection: keep-alive Content-Length: 36 Accept: text/plain, */*; q=0.01 Origin: http://localhost:8080 User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.160 Safari/537.22 Content-Type: application/json Referer: http://localhost:8080/ Accept-Encoding: gzip,deflate,sdch Accept-Language: ja,en-US;q=0.8,en;q=0.6 Accept-Charset: Shift_JIS,utf-8;q=0.7,*;q=0.3
実際のレスポンスのヘッダ
HTTP/1.1 200 OK Access-Control-Allow-Origin: http://localhost:8080 Content-Type: application/json;charset=UTF-8 Content-Length: 0 Server: Jetty(8.1.8.v20121106)
このリクエストでは、preflightのレスポンスのAccess-Control-Request-HeadersフィールドでContent-Typeフィールドが許可されているContent-Typeフィールドが付加されているのが確認できます。ここでもOriginフィールドは必ず付加されます。レスポンスでは、Access-Control-Allow-Originフィールドがやはり付加されています。
クライアント側でCORSを利用してうまく動かない際のチェックポイント
実際にクライアント側からCORSのクロスドメインアクセスをする際に、最初はなかなかうまくリソースにアクセスできないことがありましたので、チェックポイントをまとめてみました。
ブラウザがCORSに対応しているか
ほとんどのモダンブラウザでは心配はいらないはずですが、念のためXMLHttpRequestがCORSに対応しているかチェックします。XMLHttpRequestが"withCredentials"プロパティを持っているか調べるのが一般的な方法のようです。jQueryなどを利用せずにAjaxを利用するとこの手のトラブルはすぐに発見できます。
以下のソースコードは、CORSの非同期リクエストを送信する際の定石となっているコードです。XMLHttpRequestがCORSに対応していないIE 8,9にも対応できます。
function createCORSRequest(method, url) { var xhr = new XMLHttpRequest(); if ("withCredentials" in xhr) { xhr.open(method, url, true); } else if (typeof XDomainRequest != "undefined") { xhr = new XDomainRequest(); xhr.open(method, url); } else { xhr = null; } return xhr; } var xhr = createCORSRequest("GET", "http://hogehoge/resource"); // XHR2では、onreadystatechangeの代わりにonload, onerrorで結果をハンドリングできる xhr.onload = successHandler; // 成功時ハンドラ xhr.onerror = errorHandler: // 失敗時ハンドラ xhr.send();
リクエストヘッダにOriginフィールドが付加されているか
リクエストヘッダにOriginフィールドが付加されていないと、サーバー側はCORSであると判断してくれません。クライアントのオリジンサーバとサーバーのドメインが異なっているにも関わらずOriginフィールドがリクエストヘッダに付加されていない場合、ブラウザがCORSに対応しているかを疑ってみましょう。
preflightが発生する条件に該当しているか
まずは、クロスドメインのリソースに対するアクセスが、preflightが発生する条件に該当しているかをチェックします。preflightが発生する条件に該当しているにも関わらず、OPTIONSメソッドのリクエストが送信されていない場合はContent-Typeが指定されているかチェックしてみて下さい。
リソースにアクセスできない場合はリクエスト・レスポンスヘッダをチェック
クロスドメインアクセスに失敗した際には、リクエストヘッダのAccess-Control-Request-から始まるフィールドと、レスポンスヘッダのAccess-Control-Allow-*から始まるアクセス制御情報が含まれるフィールドの内容を突き合わせます。特にAmazon S3のように、クロスドメインアクセスに失敗した場合に403ステータスとレスポンスヘッダの情報しか返ってこないサーバーでは、ここからエラーの手がかりを得るしかありません。
まとめ
CORSの仕様はそれほど大きくありませんし、実装に関してもそれ程手間はかかりません。特にクライアント側はブラウザがほとんど実装を肩代わりしていてくれるのでとても楽です。まだ対応しているサービスはあまり多くないので、仕様が固まって各種サービスで実装されるのが待たれます。
次回は、本題であるCORSを利用したAmazon S3へのファイルアップロードについて書きたいと思います。