2012年9月12日水曜日

Cross-Origin Resource Sharing(CORS)で外部ドメインからリソースを取得する

今回はCross-Origin Resource Sharing(CORS)で外部ドメインからリソースを取得するお話。実際にどんな用途があるかというとGoogle Analyticsなんかで埋め込むスクリプトは埋め込む先のページのドメイン(仮にwww.matsuo-software.comとする)から違うドメイン(GAではwww.google-analytics.com)へ情報を飛ばしたりする(注1)のでそういう場合に必要となる。
注1:GAのコードを調べたわけではないので実際には不明です。

ではなぜJavascriptから外部ドメインへのアクセスにCORSが必要かというと、ブラウザにはセキュリティのためにSame Origin Policyというのが適用されている。そのPolicyは外部ドメインからのリソースに訳の分からんことをされて悪意のあるデータをPOSTされたりするのを未然に防ぐためにある。で、その防止策の一環で外部ドメインへ向けてのJavascriptを使った単純なAjaxリクエストを行ってもブラウザレベルでアクセスを拒否されてしまうので、その制限をかいくぐるのにCORSの設定が必要となるわけだ。


サンプル
例によってサンプルはNode.js。https://github.com/yooontheearth/cors-sampleからコードは取得してもらいたい。アプリの設定まわりはBackbone.jsでクライントスクリプトをガリガリ組むためのお手軽チュートリアルを参照してもらいたい。

NODE_PATH=/usr/local/lib/node_modules coffee app
でNode.jsを起動してからcorssample.htmlをブラウザで開き「Hi Mr. Yoo!」と表示されれば正常に動作している。

CORSでGETをするのは特に何の問題もなくできるはずなので、今回は少しひっかかるところのあるPOSTを解説している。


解説
今回のサンプルはいたってシンプル。見るべきところは13行目のretrieveNameと25行目のapp.post '/mrnobody'の部分のみ。
express = require 'express'
qs = require 'querystring'
app = express.createServer()

app.configure ->
 app.use express.logger('tiny')
 app.use express.bodyParser()
 app.use express.methodOverride()
 app.use app.router
 app.use express.static(__dirname + '/')
 app.use express.errorHandler({ dumpExceptions: true, showStack: true })

retrieveName = (req, callback) ->
    name = req.body.name
    if name?
        callback name
    else
        data = ""
        req.on "data", (chunk)-> data += chunk
        req.on "end", ->
            parsedData = qs.parse data
            name = parsedData.name
            callback name

app.post '/mrnobody', (req, res) ->
  retrieveName req, (name) ->
     res.header "Access-Control-Allow-Origin", '*'
     res.header "Access-Control-Max-Age", '86400' # 1 day
     res.header 'Access-Control-Allow-Methods', 'POST,OPTIONS'
     res.header "Access-Control-Allow-Headers", "X-Requested-With"
     res.send "Hi Mr. #{name}!"

app.listen 3000
console.log "Express server listening on port #{app.address().port} in #{app.settings.env} mode"
retrieveNameはそのままPOSTされたデータ(名前)を取得している。req.body.nameがNullだったら、という処理の理由は後述する。/mrnobodyのリクエストハンドラでCORSに必要なヘッダー情報を設定している。ブラウザはここで設定しているレスポンスのヘッダー情報をもとにCORSが許可されているかの判断を行う。Access-Control-Allow-Originに*を指定するとすべてのドメインからのアクセスを許可することになる。特定のドメインのみ許可したい場合は「http://www.matsuo-software.com」などを指定すればよい。またAccess-Control-Allow-Methods、Access-Control-Allow-Headersでそれぞれ許可するHTTPメソッド、ヘッダーを指定している。許可された以外のアクセスが行われた場合はアクセス拒否されるという寸法だ。それらの許可内容をAccess-Control-Max-Ageで指定した秒数だけブラウザの方でキャッシュされる。

これだけでサーバサイドのCORSの設定は完了だ。後はクライアントから通常のAjaxリクエストと同じように処理を行えば正常動作するはずだ。リクエスト時にエラーが出る場合はjQuery.support.cors = true;やcrossDomain:trueの設定をサンプルのcorssample.htmlを参考に同様に処理してもらいたい。

ただこのままで動くのはFirefoxやChromeだけのモダンなブラウザのみ。


INTERNET EXPLORER
Yes, IE! そう、IE。Web開発者の鬼門、IE。CORSでも堂々のお邪魔虫っぷりを十二分に発揮してくれる。本当にいつもいつも鬱陶しいことこの上ない。

IE10未満はXmlHttpRequestでのCORSを許可しておらず、CORSのためにはそれようのオブジェクトを使用する必要がある。XDomainRequestがそれにあたる。それなのでIEの場合はXDomainRequestを使用するようにしなければならない。

というわけで下記ライブラリでXDomainRequestを使ったAjaxリクエストをさくっと行う。
Malvolio / ie.xhr

呼び出しはこんな形になる。
$.ajax('http://localhost:3000/mrnobody', {
 data: { name: 'Yoo' },
 type: 'post',
 xhr: window.IEXMLHttpRequest || jQuery.ajaxSettings.xhr,
 crossDomain: true,
 success: function (data, textStatus, jqXHR) {
  $result.text(data);
 },
 error: function (jqXHR, textStatus, errorThrown) {
  $result.text('Failed to load data.' + errorThrown);
 }
});

で、これで終わりかと思いきやさすがはIE。さらに鬼門が用意してあってデータをポストしてるのにContet-typeをtext/plainとしてリクエストする。そのため通常ならばapplication/x-www-urlencodedでリクエストされた場合はポスト内容の解析を行ってくれるサーバサイドフレームワークがtext/plainなため内容解析を行わず、前述のreq.body.nameがNullになるというような事態を惹起するのである。そこでretrieveNameで行っているようなリクエストから生のポストデータを取得してkey=valueのペアになっている値を独自に解析する必要があるというわけである。

ここまでやって、やっとIEでもCORSができるようになった。IEでのCORSの詳しい制限事項はXDomainRequest - Restrictions, Limitations and Workaroundsを参照してもらいたい。