window.nameによるクロスドメインXMLHttpRequestを実装してみる

前回のつづき。

さて、大体 window.name によるクロスドメイン通信がどんなものかで、dojo のwindowNameモジュールがどんなことやってるかはわかった。個人的にdojoはすばらしいことをやっていると思うが、これだけのために常にdojoを使う気にはならない。ので、可能な限りポータブルなライブラリを実際に自分で実装してみることにする。

実装前におさえておきたい前提

  1. クロスドメインでリクエストを送るためには、ターゲットとなるリソースが配置されているサーバの同一ドメイン上に、プロキシの役割を担うHTMLファイルがあらかじめ配置可能である必要がある。これはFlashで例えればcrossdomain.xmlのようなもので、リソース側がクロスドメインのリクエストをオプトインしている、と考えればよい。さらにポリシーを記述することができるという点でもよく似ている(これは後述)。
  1. 何らかの静的ファイルがリクエスト送信元のページと同一ドメインに置かれている必要がある。これはframeをつかったクロスドメイン通信(e.g. Fragment Identifier Messagingとか) に共通で、レスポンスデータを含んだframeを最終的にリクエスト送信元のアクセス可能なエリアに戻すための宛先として用いられる。例えばGoogle GDataのJavaScriptライブラリではページに含まれる同一ドメインの画像ファイルを自動的に検索して選択し、dojoの場合は別途htmlファイルをリクエスト送信元になるクライアントのドメインに配置することになっている。この辺りは他の方法(JSONPFlashのcrossdomain.xml)などと比べて若干特殊。

サンプルクライアント(xd-client.html)

以下ような形でクロスドメインリクエストが送信できるようにする。

<script type="text/javascript" src="json2.js"></script><!-- http://www.json.org/json2.js -->
<script type="text/javascript" src="xd-client.js"></script>
<script type="text/javascript">
// window.nameによるクロスドメインXHRを橋渡ししてくれるHTML
var xdProxyUrl = 'http://webservice.example.com/xd-proxy.html';
var xdxhr = new WindowNameXDomainRequest(xdProxyUrl);

// http://webservice.example.com/users/1/friends.xml を取得
xdxhr.request({
  method : 'GET',
  path : '/users/1/friends.xml'
}, function(response) {
  if (response.status == 200) {
    console.log(response.body)
  }
});
</script>

クライアントJS(xd-client.js)

var WindowNameXDomainRequest = function(xdProxyUrl) {
  this.xdProxyUrl = xdProxyUrl;
};

WindowNameXDomainRequest.prototype = {

  /**
   * リクエストの送信
   */
  request : function(req, callback, scope) {

    // 隠しIFRAMEを作成
    var ifr = window.document.createElement('iframe');
    ifr.style.width = ifr.style.height = '0px';
    ifr.frameBorder = 0;
    document.body.appendChild(ifr);

    // リクエスト情報をJSON文字列としてシリアライズし、iframeのwindow.nameにセットする
    ifr.contentWindow.name = JSON.stringify(req);

    // ページと同じドメインにある画像ファイルを探し出す.
    // 存在しない場合は現在のページを戻り先にするが、
    // 同一URLが入れ子になるため、条件によっては不具合が出る可能性あり。
    var currentUrl = location.href;
    var retUrl = findSameDomainImage(currentUrl) || currentUrl;

    // iframeをプロキシHTMLのURLに遷移
    // 戻り先のURLはハッシュ(Fragment Identfier)で渡される
    ifr.contentWindow.location.href = this.xdProxyUrl + '#' + encodeURIComponent(retUrl);

    // 同じドメインに戻ってくるまでポーリング
    var PID = window.setInterval(function() {
      try {
        // iframeのドメインが異なればセキュリティ例外になる
        if (ifr.contentWindow.location.href==retUrl) {
          // window.name にセットされているレスポンス文字列をパースする.
          // (ここで妥当性検証可能)
          var res = JSON.parse(ifr.contentWindow.name);
          ifr.parentNode.removeChild(ifr);
          window.clearInterval(PID);
          callback.call(scope, res);
        }
      } catch(e) {}
    }, 100);

  }

}

...

プロキシJS(xd-proxy.js)

/**
 * フレームロード時に自動的に実行
 */
(function() {
  var m = location.href.match(/#(.+)$/);
  if (!m) return
  // 戻り先のURLを取得
  var retUrl = decodeURIComponent(m[1]);

  // リファラの情報
  m = document.referrer.match(/^https?:\/\/([^\/]+)/);
  var domain = m && m[1];

  // リクエスト情報をパース
  var request = JSON.parse(window.name);

  // ポリシーのチェック。許可されたドメインからのリクエストか、など
  if (!isAllowed(request, domain)) {
    callbackToOriginDomain({
      status : 403,
      body : 'Access forbidden by cross domain policy'
    })
    return;
  }

  // ヘッダにリクエスト元の情報を付加し、サーバサイドでのアクセスコントロールも可能にする
  request.headers = request.headers || {};
  request.headers['X-XD-REQUEST-DOMAIN'] = domain;

  xhrCall(request, callbackToOriginDomain);

  function callbackToOriginDomain(response) {
    window.name = JSON.stringify(response)
    window.location.href = retUrl;
  }

})();

/**
 * クロスドメインリクエストのポリシーを定義
 */
function isAllowed(request, domain) {
  if (typeof xdRequestPolicy == 'object') {
    if (xdRequestPolicy.allowedMethod) {
      var allowedMethod = new RegExp('^(?:'+xdRequestPolicy.allowedMethod.replace(/\s*,\s*/g, '|')+')$', 'i');
      if (!allowedMethod.test(request.method)) return false;
    }
    if (xdRequestPolicy.allowedDomain) {
      var allowedDomains = xdRequestPolicy.allowedDomain.split(/\s*,\s*/);
      for (var i=0, len=allowedDomains.length; i<len; i++) {
        var allowedDomain = allowedDomains[i].replace(/(?=\.)/g, '\\').replace(/(?=\*)/g, '.');
        if (new RegExp('^'+allowedDomain+'$').test(domain)) return true;
      }
      return false;
    }
    return false; // default no access
  }
}

プロキシHTML (xd-proxy.html)

<html>
  <head>
    <script type="text/javascript" src="json2.js"></script><!-- http://www.json.org/json2.js -->
    <script type="text/javascript">
// ポリシーの設定(例:すべてのドメインからGET,POSTを許す)
var xdRequestPolicy = {
  allowedRequest : 'GET,POST',
  allowedDomain : '*'
}
    </script>
    <script type="text/javascript" src="xd-proxy.js"></script>
  </head>
  <body>
  </body>
</html>

ソース

以上のソースはCodereposに。チェックアウトしてどこか違うドメインのWebサーバに配置し、client/xd-client.html を開けばいろいろテストできる。

http://svn.coderepos.org/share/lang/javascript/WindowNameXDomainRequest/

補足

まず、(ソースは確認できなかったが)dojoがやってるというiframeの二重化+getter定義は実装できてない。そのためFirefox 2がクライアントであるときに攻撃される可能性がある。でもまずは通信の流れを分かり易く記すことが重要ではないかと考え、簡潔に実装している。

ポリシーの適用に関しては、当初戻り先のURLが渡されればそこからリクエストソースも判定/対応できるかと思っていたが、ポリシーに適合する別ドメインを詐称することも可能(遷移した後で親ウインドウから強制的にフレームを書き換える)なため、結局リファラになった。つまりクライアントからリファラが送られない状況では対応できない。何か方法ないか検討したい。