Category Archives: Web技術

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を正しく使うことに比べて、まだリスクがあるかもしれません。

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

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

MayaaでHTML5のスマートフォンページを作る際にはまったこと

僕はMayaaが好きです。仕事でもかなり使っています。

今回、スマートフォン(iPhoneおよびAndroid対応)向けECサイトのフロントエンドに、Mayaaを使用しました。そこではまったことを報告します。

metaタグにContentTypeが省略できるようになったため、ドキュメント判別の手段が減ってしまった

HTML5では、

<meta charset="UTF-8">

のような書き方が許されるようになりました。

従来は

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />

という書き方をしていました。
これで何が問題なのかというと、Mayaa自体がこのContent-Type指定を読んでいてそれによって処理を判別(例えばapplication/xhtml+xmlかどうかなど)もしているので、動作が異なってしまう場合があります。

私たちの場合は、内部的な事情で、テンプレートファイルの拡張子に.xhtmlを使用していたため、すべてのContentTypeヘッダがapplication/xhtml+xmlになってしまい、ブラウザのバリデーションエラーで画面が真っ白になるという現象が発生しました。

回避方法は、前者の省略記法を使用せず、後者の従来通りの書き方をすることです。この書き方はHTML5文法としても正式です。

aタグがブロック要素を包含できるようになったのに、うまくいかない

この問題は意外と深かったです。テンプレートに

<a href="javascript:alert('hello');">
       <p>aaa</p>
       <p>aaa</p>
</a>

のように記述すると、実行時に勝手に以下のように書き換えられてしまいます。

<a href="javascript:alert('hello');">
       </a><p><a href="javascript:alert('hello');">aaa</a></p>
<p>aaa</p>

これは、Mayaa依存ライブラリのNekoHTMLが、パースした段階でDOMツリーを再構成してしまうためで、NekoHTMLがHTML5に対応していないため、XHTMLやHTML4以前の仕様に基づいて、DOMツリーを構成してしまいます。

この件について、現時点で一番良い回避法は、
http://ml.seasar.org/archives/mayaa-user/2011-March/000923.html
こちらで対応してくださった方法を使用することです。しかし、この場合は、imgやbrなども含めて必ず閉じタグを書かなければなりません。テンプレートコーディング者が第三者の場合などその徹底が難しい場合は使用が難しいと思います。

NekoHTMLについては、本家MLに問い合わせたところ、最新版でもHTML5への特別な対応はしていないとの返答をいただきました。

最終手段として、私は、NekoHTMLのソースを直接書き換えました。修正箇所は、Mayaaが使用しているNekoHTML-0.9.5をベースとして、

org.cyberneko.html.HTMLElements の、

184:     new Element(A, "A", Element.INLINE, BODY, null),

184:     new Element(A, "A", 0, BODY, null),

に書き換えることです。

NekoHTMLにはテストコードが付属しているので、実行してみたところ、2箇所でエラーになりました。

test:
[tester] Parsing test files and generating output...
[tester] Comparing parsed output against canonical output...
[tester] test36.html:5 strings don't match
[tester] [in: )A]
[tester] [out: (P]
[tester] test50.html:5 strings don't match
[tester] [in: )A]
[tester] [out: (P]
[tester] Finished with errors.

BUILD FAILED

これは、まさに今回の修正の対象だったので、テストケースを修正します。
/data/html/canonical/test36.html
修正前:

(HTML
(BODY
(A
Aname foo
)A
(P
(A
Aname foo
"Blah
)A
)P
)BODY
)HTML

修正後:

(HTML
(BODY
(A
Aname foo
(P
"Blah
)P
)A
)BODY
)HTML

/data/html/canonical/test50.html
修正前

(HTML
(BODY
(A
Ahref foo
)A
(P
(A
Ahref foo
"Blah
)A
)P
)BODY
)HTML

修正後

(HTML
(BODY
(A
Ahref foo
(P
"Blah
)P
)A
)BODY
)HTML

test:
[tester] Parsing test files and generating output...
[tester] Comparing parsed output against canonical output...
[tester] Done.

BUILD SUCCESSFUL

これで、独自にビルドしたnekohtmlのjarをアプリのlibにコピーし、nekohtml-0.9.5.jarを削除すれば差し替え完了です。

JavaScript+HTMLのみでテキストボックスのplaceholder実装メモ

よくあるWebフォームの表現で、入力欄が空欄の時は「入力してください」のようなデフォルトの文言が出ていて、フォーカスを当てると空欄になるような仕掛けがあります。この表現は、placeholderとか、watermarks(透かし)と呼ばれるようで、HTML5の場合は標準機能のようです。また、jqueryを使用している場合はプラグインもあるようですが、既存のページで対応しなければいけない場合のための、ライブラリなしの実装しました。地味にはまったので、メモを残します。

なお以下の実装を導き出す際、@Otchyさんに助言を参考にさせていただきました。ありがとうございます。
http://twitter.com/otchy/status/37740505953468416
http://twitter.com/otchy/status/37742189345775616

実装手順

例えば備考フォームを作ろうとしたとします。

<html><body>
<form name="form1" method="GET" action="http://www.google.com">
<textarea name="biko"></textarea>
<input type="submit">
</form>
</body></html>

bikoの下にダミーのテキストエリアを配置し、displayをnoneにします。

<textarea name="biko_dummy" style="display: none">ご要望などはこちらに入力してください。</textarea>

これでは初期表示時に、ダミーが表示されません。元のHTMLをいじって、

<textarea name="biko" style="display: none"></textarea>
<textarea name="biko_dummy">ご要望などはこちらに入力してください。</textarea>

このようにすれば、問題ないですが、万が一、JavaScriptが動かなかった場合、メインが表示されなくなってしまいます。そこで、この書き方はせず、JavaScriptで以下のようにします。

document.forms.form1.biko.onblur();

ところが、この書き方ではIE8では、「戻る」ボタンがうまく動きませんでした。戻るボタンの時にJavaScriptが走ってしまい、その時点では、まだbikoの値がロードされていないようで、必ずダミーが出現してしまいます。

以下のようにすることで回避できました!
setTimeout(function() {document.forms.form1.biko.onblur()}, 10);

if (window.attachEvent) {
window.attachEvent('onload', function() {document.forms.seisanForm.biko.onblur();});
} else {
document.forms.seisanForm.biko.onblur();
}

完成コード

<html>
<body>
<form name="form1" method="GET" action="http://www.google.com">
<textarea name="biko"></textarea>
<textarea name="biko_dummy" style="display: none">ご要望などはこちらに入力してください。</textarea>
<input type="submit">
</form>
<script type="text/javascript">
function onblurForm1Biko() {
   if (this.value == '') {
       this.style.display = 'none';
       this.form.biko_dummy.style.display = 'inline';
   }
}
function onfocusForm1BikoDummy() {
   this.form.biko.style.display = 'inline';
   this.style.display = 'none';
   this.form.biko.focus();
}
document.forms.form1.biko.onblur = onblurForm1Biko;
document.forms.form1.biko_dummy.onfocus = onfocusForm1BikoDummy;
if (window.attachEvent) {
   window.attachEvent('onload', function() {document.forms.seisanForm.biko.onblur();});
} else {
   document.forms.seisanForm.biko.onblur();
}
</script>
</body>
</html>

今回の場合、フォーム送信の時に "biko_dummy"の値も送信されてしまう問題があり、それを回避する場合は、textareaではなく、spanかなにかにすれば良いのですが、そうすると、デザイン調整とか、onfocusが使えないなど面倒なところもあるので、今回は上記実装で妥協しました。

以下、動作するサンプルです。

http://s-ishigami.appspot.com/files/placeholder.html

2011/02/18追記

setTimeoutよりもonloadの方が美しいと指摘を頂いたので、修正しました。ただ、既にonloadが使われている場合に厄介なので、attachEventを、(今回はIEだけの問題なので)存在チェック付きで使用しました。

やはり、jQuery使いたいですよね。