Monthly Archives: 9月 2011

アンドロイドは猫さんとは仲がわるいのか

こんにちは。またまたはまりましたので報告します。このところ連投だなあ(笑)

問題概要

Android端末で毎画面セッションIDが変わるという現象が開発用のローカル環境で発生し、延々と調べてしまいました。
原因は、http://d.hatena.ne.jp/s-ishigami/20110916/p1と同じで、セキュア属性付きのCookieをHTTPで変更できない件によるもので、それが、JSESSIONIDで発生していました。

<

div class="section">

原因

こういうことのようです。

「JSESSIONIDを保持したCookieをsecure属性にする方法」
http://www.atmarkit.co.jp/bbs/phpBB/viewtopic.php?topic=5722&forum=12

hreq(HttpServletRequest) がSSLであれば、Secure属性を付けているようです。

セッションが初回(または無効)で、HTTPSでアクセスされたと判断すると、TomcatはSet-Cookieレスポンスヘッダをsecure属性付きで返します。この仕様は変えられないようです。これはこれで望ましいのですが困ってしまうことがあります。

以下は検証コードです。

public class MyServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
    throws ServletException, IOException {
        try {
            String path = request.getRequestURI().substring(request.getContextPath().length());
            if (path.equals("/cookie")) {
                request.getSession(true); // session start
                response.setContentType("text/plain; charset=UTF-8");
                PrintWriter writer = response.getWriter();
                writer.println("request cookies: ");
                if (request.getCookies() != null) {
                    for (Cookie reqCookie : request.getCookies()) {
                        writer.println("\\t" + cookieToString(reqCookie));
                    }
                }
            }
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }
    prcted String cookieToString(Cookie cookie) {
    return cookie.getName() + "=" + cookie.getValue() + "\\n\\t\\t" +
    "d: " + cookie.getDomain() +
    ", p: " + cookie.getPath() +
    ", v: " + cookie.getVersion() +
    ", a: " + cookie.getMaxAge() +
    ", c: " + cookie.getComment() +
    ", s: " + cookie.getSecure();
    }
}


PCやiPhoneのブラウザでは、セキュア属性付きでセッションがスタートした直後にHTTPページへ遷移した場合、そのセッションが切れますが、新しいセッションが開始し、ブラウザもそのセッションIDをCookieで受け取ります。が、AndroidはこのCookieを拒否してしまいます。

解決方法

ロードバランサにSSLを処理させている場合は、TomcatがHTTPSかどうかを判断することが出来ず、常にHTTPだと認識して動作しているので、問題ありません。(リバースプロキシを使用していない場合)

しかし、もし、

  • LBやSSLアクセラレータを使っていない
  • AndroidでアクセスされるWebサービス
  • HTTPとHTTPSを行ったり来たりする
  • 「初回は必ずHTTPである」ことを保証できない(HTTPS操作中にセッション切れになる場合も含めて)

このような性格のサービスの場合は注意が必要です。

取れる対策としては

  • 全てSSLにする
  • セッションが無効で、HTTPSアクセスの場合は、HTTPにリダイレクトする
  • Cookieを使わない

などになると思います。

結論

AndroidとTomcatは仲が悪い(・へ・)

2012/03/04 追記

セッションハイジャック保護のため、SSLを使用したページではセキュアなセッションIDを発行すること自体は望ましいことです。IPAも推奨しています。しかし、httpとhttpsを行き来するケースでは、jsessionidを直接secureにして欲しくなく、別にセキュアなsessionidを投げて、httpで使用するjsessionidと紐付けを行うという実装方法が多く取られていると思います。

自分のアプリケーションはそのような構成になっていたのですが、まさかjsessionid自体をsecureにされるとは思っていませんでした。

実際はLBの後ろ側にAPサーバを配置するケースが多いと思います。開発環境でオレオレ証明書でのテストが「何故か動かない」と、納期直前に泣きそうになった開発者の記録です。。。

MayaaなどRhinoを使っていてハマること。It is not a function, it is String

またまたドハマリをしたので報告します。シチュエーション的にはまれだと思いますが、同じ罠にはまると、解決するのに時間がかかると思うので報告します。

現象としては、以下の2記事の組み合わせです。

TreeSetのComparatorではまったのでメモ(初心者向け)
http://d.hatena.ne.jp/s-ishigami/20110615/p1

噛み砕いていうと「compareの結果が0と、equalsが同値でないと、TreeSetは異常な動きをするよ」というところです。

そろそろ2年間Mayaa使ってわかったことを書く
http://d.hatena.ne.jp/s-ishigami/20110708

mayaaでは、Javaオブジェクトとテンプレートとの橋渡しにJavaScript(Rhino)を使用します。〜略〜「Javaのように見えて少し違う」書き方をしなければなりません。Javaだと思って書いていると、うっかり思いもよらないバグを作ってしまってハマります。

今回はこの具体例の好例だと思います。

コード例

実務では書かないと思いますが、実験のため以下の様なコードを書いたとします。

public class Example {
    public static SortedMap<String, String> newNumberSortedMap() {
        return new TreeMap<String, String>(new Comparator<String>() {
        @Override
        public int compare(String o1, String o2) {
            int i1 = 0, i2 = 0;
            try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
            try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
                return i2 - i1;
            }
        });
    }
}

ここでやっていることは、"1", "2"のように、数字だけで構成された文字を入れたとすると、"1", "10", "2"...のようなアスキー順にならず、"1", "2", "10", ...のように数値順に列挙できるマップをつくろうとしています。数字以外をキーするケースはビジネス上考えなくてよく(バリデートされているか、DBで検査されているとする)、万が一設定された場合は数値"0"として判断されます。

mayaaファイルをこのように実装します。実務ではないかもしれませんが、mapに値を格納して、エントリーセットを変数に格納しています。

<?xml version="1.0" encoding="UTF-8"?>
<m:mayaa xmlns:m="http://mayaa.seasar.org">
<m:beforeRender>
<![CDATA[
      var map = Packages.com.example.Example.newNumberSortedMap();
      map.put(\'0\', "zero");
      map.put(\'1\', "one");
      map.put(\'2\', "two");
  ]]>

これを実行するすると、以下のようなエラーが発生します。

TypeError: Cannot call property put in object {0=zero}. It is not a function, it is "string".

原因

これはどういう事でしょうか?直訳すると、「プロパティー"put"をオブジェクト{0=zero}にcallできません。それは関数ではなく、それはstringです。」です。JavaScriptのことがわからないとこのエラーは意味不明です。

JavaScriptには、以下のようなシンタックスシュガーがあります。

var hoge = { a: 'a' };

このとき、

hoge.a

hoge['a']

は同等です。したがって、先程の

map.put('1', 'one')

は、

map['put']('1', 'one')

のように変化します。

このままなら、map['put']が、Javaのmap#put(String,String)を呼び出す関数オブジェクトになって、それにパラメータ('1', 'one')が渡されるだけなので、問題ありません。

ところが、さらに、今後はスクリプトエンジンRhino特有のシンタックスシュガーが悪さをします。RhinoはJSPのEL式のような書式をサポートしています。例えば、

hoge.fuga

と書くと、

hoge.getFuga()

へのショートカットになったりします。Map型に対してはさらに、

map['hoge']

が、

map.get("hoge")

に割り当てられます。すると、先ほどの式は、

map.get("put")

へと変化します。

続いて、このMapオブジェクトのgetの実装に処理は移譲されます。このmapは上記では、独自Comparatorを搭載したTreeMapです。このTreeMapはアプリケーションの制約により、数字以外の文字列を格納しないルールでした。数字以外の文字列を格納したときの動作は保証されません。具体的には、最初にリンクをした記事にあるように、TreeMapのgetはComparatorで0になるキーの値を返してしまいます。そのため、

map.get("put")

map.get("0")

と同じ結果を返してしまいます。運が悪いことに、直前に

map.put("0", "zero")

が呼び出されていました。そのため、この式は"zero"というString値を返してしまいます。このままJavaScript側に処理を戻すと、今このようになっています。

'zero'('1', 'one')

まとめ

上記をまとめると以下のようになります。

map.put('1', 'one');
map['put']('1', 'one');        // JavaScriptのシンタックスシュガー
map.get("put")('1', 'one'); // Rhinoのシンタックスシュガー
map.get("0")('1', 'one');    // Comparatorの実装ミス
"zero"('1', 'one');

はい、

It is not a function, it is "string"

というわけです(笑)

修正方法

今回の問題の根本原因はComparatorの実装にバグがあることでした。正しい実装は以下のとおりです。(キーにnullを許容しない場合、許容する場合は上記の記事の最後に正解があります)

@Override
public int compare(String o1, String o2) {
    int i1 = 0, i2 = 0;
    try { i1 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
    try { i2 = Integer.parseInt(o1); } catch (NumberFormatException e) {}
    if (i1 == i2) {
        return o1.compareTo(o2);
    }
    return i2 - i1;
}

最初に引用したとおり、TreeMapのComparatorを実装するときは、o1とo2が完全に一致する場合を除いて0を返してはいけません。そのようなコードを書いたときの動作はAPIによって保証されていません。

今回は、SunVMで検証したので、もしかしたらVMによっては動作が異なるかもしれません。

考察

今回は、TreeMapで陥りやすい罠、にMayaaあるいはテンプレート系DSL全般で陥りやすい罠が組み合わさって非常に発見しない不具合が発生してしまいました。まだおかしな動作をしてデータを破壊することはなく、例外で落ちていたからましかも知れません(前回TreeMapで問題を起こしたときは最悪データを壊す可能性がありました)

言えることは、他言語間(JavaとJavaScriptなど)のコラボレーションはシームレスには行かないということです。境界がある以上どこかにほつれは必ず存在します。そのための方針としては、両者の役割をできるだけ分離し、境界でコードが動くことを少なくすることです。JavaとJavaScriptを行ったり来たりするケースでは、プリミティブ型とString、配列など典型的な型のみを使用し、やむなくListやMap、その他複雑なオブジェクトを使う場合は十分に注意するべしというところです。

それは分かっていても、今回のような問題は発生します。そこでできるのは情報共有だと思います。少しでも誰かの役に立てるよう、今回のように問題にはまったときは、これからも情報を発信し続けようと思います。

Androidの標準Webブラウザでセキュア属性付きのCookieの扱いがPCやiPhoneと異なる件について

Android用のWebサイトを作っていてはまったので、報告します。対象はブラウザ上で動くWebアプリの話で、ネイティブアプリではありません。
Googleで検索しても、ネイティブアプリ関連の情報は出てきますがWeb開発の情報が意外とすくなかったので、少しでもお役に立てれば幸いです。

概要

Android標準ブラウザ(Dolphinブラウザなども含む)で、セキュア属性付きのCookieの挙動がiPhoneのブラウザや、PCのChromeなどと異なります。

Cookieやセキュア属性の概要はこちらなどをご参照ください。http://itpro.nikkeibp.co.jp/article/COLUMN/20080221/294407/

PCやiPhoneのブラウザの場合、WebサーバがSet-Cookieレスポンスヘッダを返した場合、例え、そのCookieのセキュア属性が設定されていても、ブラウザはそのCookieを受け取ります(通称:食べます)。Chrome13、iOS 4.3.4で確認しました。
ところが、AndroidのWebブラウザでは、保持しているCookieにセキュア属性が付いている場合、HTTPSによって暗号化されたレスポンス以外ではCookieを受け取りません。Nexus S(Android 2.3)、Xperia Arc(Android 2.3)、HTC Desire(Android 2.2)で確認しました。

検証

再現コードは以下の通りです。確認するためには、自前でSSL付き(オレオレ証明書でも良い)のアプリサーバを立てることが必要です。誰かがappengineで立ててくれると便利ですね!(←お前がやれかw)

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Date;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class MyServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;

    @Override
    protected void doGet(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        try {
            String path = request.getRequestURI().substring(
                    request.getContextPath().length());
            if (path.equals("/cookie")) {
                System.out.println("start");
                String val = String.valueOf(new Date().getSeconds());
                Cookie cookie = new Cookie("TEST1", val);
                Cookie cookie2 = new Cookie("TEST2", val);
                cookie.setSecure(true);
                response.addCookie(cookie);
                response.addCookie(cookie2);
                response.setContentType("text/plain; charset=UTF-8");
                PrintWriter writer = response.getWriter();
                writer.println("request cookies: ");
                if (request.getCookies() != null) {
                    for (Cookie reqCookie : request.getCookies()) {
                        writer.println("t" + cookieToString(reqCookie));
                    }
                }
                writer.println("response cookie: ");
                writer.println("t" + cookieToString(cookie));
                writer.println("t" + cookieToString(cookie2));
            }
        } catch (Exception e) {
            throw new ServletException(e);
        }
    }

    protected String cookieToString(Cookie cookie) {
        return cookie.getName() + "=" + cookie.getValue() + "ntt" + "d: "
                + cookie.getDomain() + ", p: " + cookie.getPath() + ", v: "
                + cookie.getVersion() + ", a: " + cookie.getMaxAge() + ", c: "
                + cookie.getComment() + ", s: " + cookie.getSecure();
    }
}

このようにすると、

request cookies:
TEST2=19
d: null, p: null, v: 0, a: -1, c: null, s: false
response cookie:
TEST1=46
d: null, p: null, v: 0, a: -1, c: null, s: true
TEST2=46
d: null, p: null, v: 0, a: -1, c: null, s: false

のように表示されます。これを、同じブラウザでタブを切り替えて、httpsでアクセスすると、

request cookies:
TEST1=1
d: null, p: null, v: 0, a: -1, c: null, s: false
TEST2=1
d: null, p: null, v: 0, a: -1, c: null, s: false
response cookie:
TEST1=27
d: null, p: null, v: 0, a: -1, c: null, s: true
TEST2=27
d: null, p: null, v: 0, a: -1, c: null, s: false

このようになります。HTTPSの時だけセキュアなTEST1クッキーが受け取れます。

この時、PCのブラウザやiPhoneでは、request cookiesの値は、HTTPでも、HTTPSでも前回リクエストした時のresponse cookieの値が設定されています。しかしながら、Androidのブラウザでは、

request cookies:
TEST1=26
d: null, p: null, v: 0, a: -1, c: null, s: false
TEST2=9
d: null, p: null, v: 0, a: -1, c: null, s: false
response cookie:
TEST1=44
d: null, p: null, v: 0, a: -1, c: null, s: true
TEST2=44
d: null, p: null, v: 0, a: -1, c: null, s: false

のように、TEST1とTEST2が異なる値を返してしまいます。TEST1に入っているのは「前回HTTPSでアクセスしたときにresponseされたcookieです」

実験の結果より、Androidの標準ブラウザでは、HTTPSでしか、セキュア属性付きのCookieは、受け取らないことがわかりました。ただし、これにも条件があって、端末側にまだ同じCookieが存在しない場合は、HTTPでもセキュアCookieを食べています。

つまり、AndroidとiPhoneのブラウザは同じWebkitエンジンを使用していますが、セキュリティーポリシーに違いがあります。

もしかしたら、PCやiPhoneでも設定を変更するれば動作が変わるかもしれません(未確認)。しかし、標準状態で使用している人が多いですから、実質上上記の状態がWebの現状と考えてよいでしょう。

考察

AndroidのWebに対するセキュリティーポリシーは強力だと言えます。たとえ、端末やDNSサーバがクラックされて、Webサイトを偽装されたとしても、偽装のリスクがあるHTTP通信によって、より安全な(証明書がある)SSL通信に影響をあたえることができないからです。以前にもChromeで突然JavaScriptのセキュリティを厳しくされて今までのページが動作しないということがありました。(参照させて頂きます:http://blog.bitmeister.jp/?p=1734)

Googleのギークたちはセキュリティ対策に非常に厳しい姿勢で望んでいると思います。たとえ一部の古いWebサイトを動かなくしたとしても、彼らはWebの安全性を大事にするのでしょう。現在のところは、PC版のChromeでは、今回の挙動はしていませんが、将来のChromeにこのような仕様変更が入ったとしても不思議ではないでしょう。

では、我々はWeb開発者どうしたら良いでしょうか?答えの一つは「もうCookieを使うのをやめよう」ではないでしょうか。HTML5など新しい技術を常に勉強して、セキュリティリスクが少なく、よりリッチで時代に即したサイトやWebサービスを作って行きましょうというのが、彼らからのメッセージなのかもしれません。
※こんな精神論を述べるのは、迷ったのですが、最終的に掲載することにしました。お目汚し失礼しました。

追記(2012/03/03)

半年前のエントリに今頃ブクマコメントがをいただいて驚きました。こんな辺鄙なブログを閲覧いただき誠にありがたく思います。

ご指摘を頂いたとおり、Secure属性付きのCookieをWebサーバが送信していることに問題があります。これからWebアプリケーションを開発される際はそのことを厳守するべきです。
しかし、この望ましくない動作を期待していた古いアプリケーションが実際に存在し、Androidブラウザで動かないということで調査と実証、および対策をしたのが本エントリになります。

iPhoneやPCのChromeはこれら過去への互換性を優先し、Android標準ブラウザはあるべき姿を優先したと考察しましたが、この状況は記事を書いた昨年9月時点のものであり、今のiPhone5, Android4.xなどでは動作が異なる可能性もあります。

「Cookieを使わない」は言い過ぎでした。いわゆるHTML5Webアプリケーションの可能性は感じていますが本件とは直接関係がありません。

コメントを頂いた「Cookieを使わない」の具体例については、個人情報などは別にいわゆるAjax経由で取得する方法が考えられます。クロスドメインが使用できるXHR2を利用すれば、アプリケーションサーバと個人情報の格納先を分離することができます。ページ間をまたぐ場合はWebStorageを活用することで、Cookieの代替とすることができます。ただ、Cookieと同じようにこちらも攻撃者からの脆弱性を考慮する必要性は存在し、ある程度運用方法が固まったCookieを正しく使うことに比べて、まだリスクがあるかもしれません。

この記事は以前多くのブックマーク・コメントを頂きました。

これからブックマークされる方はこちら↓