pixiv insideは移転しました! ≫ http://inside.pixiv.blog/

CORSでハマったことまとめ

こちらは ピクシブ株式会社 Advent Calendar 2014 の12/16の記事です。


こんにちは、エンジニアの@dnskimoです。

先日、はじめてCORSを実装する機会があったので、覚書がてらまとめておきたいと思います。

CORSとは

Cross-origin resource sharingの略です。 読み方は「コルス」でいいんでしょうか? Same-Origin Policyに弾かれずに、異なるドメイン間でリソースを共有する仕組みです。 2014年1月にW3C勧告になり、JSONPに替わる方法として徐々に普及してきているようです(要出典)。 アクセスコントロールの仕様も定義されているので、特定のサイトからのみ利用可能なAPIを作る際などに便利です。 JSONPのような「裏ワザ」的な方法ではないところも個人的に好みです。

詳しいことはネット上に素晴らしい記事がたくさんあるので割愛します。特にこちらの記事にお世話になりました。

参考: Using CORS

CORSには上記の記事で述べられている「シンプルなリクエスト」のケースと、それ以外のケースがあるのですが、今回実装したのは前者です。 preflightリクエスト等の話題が出てくるのは後者です。

STEP1:サーバーサイド・クライアントサイドを普通に実装する

今回実装したいのは「accounts.pixiv.netにログイン済みなら、OAuth認証画面にリダイレクトする」というもの。 サーバーからは「ログインしているか否か」という情報だけを返却します。

ここでは仮にリソースプロバイダ側のエンドポイントをhttp://accounts.pixiv.net/cors/status、これを利用するサービスのルートURLをhttp://service.example.comとします。

サーバーサイド(PHP)

session_start();

$response = array('is_login' => isset($_SESSION['user_id']));

header("Content-Type: application/json; charset=utf-8");
echo json_encode($response);

クライアントサイド(CoffeeScript)

$.ajax 'http://accounts.pixiv.net/cors/status',
  dataType: 'json'
.done (data) ->
  return unless data.is_login
  location.href = '/users/auth/pixiv'
.fail (xhr, status, error) ->
  console.error status, xhr
  throw error

ここまでは通常のAjaxと同じです。

実行してみると、ブラウザの開発者ツールで以下のようなエラーが見られると思います。(Chromeの例)

XMLHttpRequest cannot load http://accounts.pixiv.net/cors/status.
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Origin 'http://service.example.com' is therefore not allowed access.

これがSame-Origin Policyにひっかかっている状態です。

STEP2:Originを許可する

CORSの最低限のやり取りは、サーバー・クライアント間でOriginの指定を揃えてやれば完了します。 クライアントサイドのOriginヘッダはブラウザが自動的につけてくれます。 また、このヘッダはJavaScript等で上書きすることが出来ない仕様になっています。

サーバーサイドはAccess-Control-Allow-Originヘッダに許可したいOriginの値をセットするだけで動きます。 また、CSRF防止の観点から、許可していないOriginであれば、そもそも処理しないのが良さそうです。

$allowed_origin = 'http://service.example.com';

if ($_SERVER['HTTP_ORIGIN'] == $allowed_origin) {
    session_start();

    header("Access-Control-Allow-Origin: $allowed_origin");

    $response = array('is_login' => isset($_SESSION['user_id']));
} else {
    $response = array('error' => '未対応のサービスです');
}

header("Content-Type: application/json; charset=utf-8");
echo json_encode($response);

Access-Control-Allow-Originヘッダに*(ワイルドカード)を指定すると、全てのドメインからのアクセスが可能になります。ただし、この方法は後述するCredentialを有効にする設定とは共存できません。

とりあえず、これで先ほどのエラーは出なくなるはずです。 しかし、このままではis_loginは常にfalseになってしまいます。

STEP3:Credentialを有効にする

CORSではデフォルトでクッキーの送受信が行われません。 また、受信できるレスポンスヘッダも最小限に絞られています。

クッキーの送受信を有効にするには、XMLHttpRequestオブジェクトのwithCredentialsプロパティにtrueをセットする必要があります。 jQueryを使っている場合は、$.ajax()メソッドのxhrFieldsパラメータ経由でセットするだけなので簡単です。

$.ajax 'http://accounts.pixiv.net/cors/status',
  dataType: 'json'
  xhrFields: withCredentials: true # New!
.done (data) ->
  return unless data.is_login
  location.href = '/users/auth/pixiv'
.fail (xhr, status, error) ->
  console.error status, xhr
  throw error

サーバー側はAccess-Control-Allow-Credentialsヘッダを返すように追記します。 これが返されない場合、クライアントサイドでエラーが発生します。

$allowed_origin = 'http://service.example.com';

if ($_SERVER['HTTP_ORIGIN'] == $allowed_origin) {
    session_start();

    header("Access-Control-Allow-Origin: $allowed_origin");
    header("Access-Control-Allow-Credentials: true"); // New!

    $response = array('is_login' => isset($_SESSION['user_id']));
} else {
    $response = array('error' => '未対応のサービスです');
}

header("Content-Type: application/json; charset=utf-8");
echo json_encode($response);

他にもAccess-Control-Expose-Headersを設定すれば、デフォルトでは受信できないレスポンスヘッダを受信できるようになります。

これで晴れてaccounts.pixiv.netのログイン状態が取得できるようになりました。 そうすると、次は本当に特定ドメインからのアクセスのみに制限できているのかを確かめたくなるのが人情だと思います。

アクセスコントロールをデバッグする

ChromeでAjaxリクエストをエミュレートするのに便利なPostmanですが、CORSでも使えます。 ただし、単体ではOriginヘッダを書き換えられないので、Postman Interceptorプラグインが必要です。

ウィンドウの右上のほうにToggle Interceptorというトグルボタン(トーテムポールのようなマーク)が出てくるので、これをアクティブにします。

f:id:devpixiv:20141216151954j:plain

Originヘッダに許可されているドメインを設定してリクエストを送信すると、正常にログイン状態が取得できます。

f:id:devpixiv:20141216153403j:plain

Originヘッダに不正なドメインを設定してみると、Originのチェック段階で弾かれているのがわかります。

f:id:devpixiv:20141216153021j:plain

IE対応・その1 XDR

ここまですんなりと実装できたと思っていたら、やっぱりありました。IE対応案件です。

jQueryの$.ajax()メソッドは内部的にXMLHttpRequestオブジェクトを操作しているのですが、IE8-9ではXDomainRequestという独自実装になっているため動作しません。 jQueryでは今のところXDomainRequestに対応する予定は無いそうです。

参考: BUILT-IN SUPPORT FOR XDOMAINREQUEST

その代わり、jQuery.ajaxTransport()を使えば$.ajax()メソッド内部で使われるハンドラの自作が可能 なので、ここにXDomainRequestオブジェクトが存在した場合の分岐を書くことができます。今回はこちらのハンドラを使わせていただきました。

また、$.ajax()メソッドを使わずに、スクラッチでリクエストメソッドを書いても、それほど複雑にはならないと思います。

この対応はIE8-9をスルーして良い案件であれば不要です。

IE対応・その2 P3P

実は、ここまでやってもIEではクッキーが送信されません。 IEはサードパーティにクッキーを送信する条件が厳しいようです。 Windowsのインターネットオプションのプライバシータブを見てみると、以下の様な文言があります。

f:id:devpixiv:20141216151227j:plain

ここで言われている「コンパクトなプライバシーポリシー」というのが、The Platform for Privacy Preferences(P3P)のコンパクトポリシーのことです。 P3Pは、古くからあるサイトの個人情報の取り扱いを明示するための技術仕様ですが、あまり普及しなかったらしく、現在ではIE以外のメジャーなブラウザには実装されていません。 今となっては、P3Pと聞いてこちらを連想する人のほうが多いのではないでしょうか。 Windowsの場合、プライバシー設定のセキュリティレベルが「低」以上であれば、IEはP3Pが設定されていないサードパーティのクッキーを送信しません。

リソースプロバイダのクッキーにP3Pを設定するには、Set-Cookieレスポンスヘッダを返す際、一緒にP3Pコンパクトポリシーヘッダを返す必要があります。 P3Pにはかなり重量級の仕様があるのですが、今は形骸化しているので、気にする必要はないようです。 とりあえずはどんな文字列でも動作します。

参考: P3P is dead, long live P3P!

header('P3P: CP="THIS IS NOT A P3P"');

今回はセッション用のクッキーのSet-Cookieに合わせて、このヘッダを返すようにしました。

実装した感想

いくつかハマるポイントはありましたが、一度コツを掴めば今後は楽に作れそうな感じでした。 普通のAjax用のAPIとして作ったものを、後からCORSに対応させるのも簡単そうです。 無制限にどこからでもアクセスさせたい、かつクッキーも送受信したいというケースでもない限りは、JSONPを積極的に使う理由はなくなるのではないでしょうか。

エンジニア募集!

pixivではWEBアプリケーションエンジニアを絶賛募集中です。

次回のAdvent Calendarは?

明日は@yudemanjyuがなにかかいてくれるそうです。