特にIE系でよく見られる「ページの期限切れ」画面。これを発生させないためにはどうすればよいのか、現在は少しGoogleで検索するだけで実に様々な対策方法が蓄積されている。だが、そもそも「ページの期限切れ」とはいったい何を示しているのか?いったいこの画面はユーザーに何を訴えているのだろうか?
今回はPHP言語に限定して、この現象を可能な範囲その原因を追及し、抜本的対策と巷間にあふれる対策方法の是非を検討する。最終的に必要となった知識はHTTPのRFC2616のキャッシュ機能およびPHPのext/session/session.cのソースコードとなった。
まず現象を再現するところから始める。「ページの有効期限切れ」画面が表示されるのはIE系なので、以下に示す各スクリプトもIEで表示させることを前提とする。Firefoxの場合の動作は後述する。
<?php session_name('CacheExpireExperiment01'); session_start(); ?> <html> <body> <form action="" method="POST"> <input type="text" value="" name=""> <input type="submit"> </form> </body> </html>
<?php session_cache_limiter('nocache'); session_name('CacheExpireExperiment02'); session_start(); ?> <html> <body> <form action="" method="POST"> <input type="text" value="" name=""> <input type="submit"> </form> </body> </html>
<?php session_cache_limiter('private'); session_name('CacheExpireExperiment03'); session_start(); ?> <html> <body> <form action="" method="POST"> <input type="text" value="" name="test"> <input type="submit"> </form> </body> </html>
<html> <body> <form action="" method="POST"> <input type="text" value=""> <input type="submit"> </form> </body> </html>
「ページの有効期限切れ」画面は主にIE系で取り上げられる現象である。一方のNetscape系(Geckoエンジン系)のブラウザではあまりそう言った話題はあがらない。では、実際どうなるのか?Firefox1.04で前掲のsession01/02.phpにアクセスし、IEの時と同様の手順を踏んでみる。
結論として、「再度POSTして良いですか?」という下図に示すような画面が表示され、OKをクリックすると再度サーバーにリクエストが送られている。(この段階ではあくまでも送られている「らしい」までしか目視確認できないが、後述のFirefoxのLiveHTTPHeadersの解析により実際にリクエストが送られていることを確認できた。)
IE/Firefoxとも、発生しないスクリプトの場合特にサーバーにアクセスも発生せず、表示の早さとフォームに入力された値を覚えてくれている辺り(実際、各Submit毎に入力した値を完全に覚えていた)も同様である。どうやらブラウザのキャッシュにアクセスしているらしい。
発生するスクリプト・しないスクリプトの違いはsession_cache_limiter()の違いである。発生するスクリプトはsession_cache_limiter()を呼んでいないか、'nocache'を渡している。実際にPHPのマニュアルを参照してみる。
http://jp.php.net/manual/ja/function.session-cache-limiter.php
これによると、この関数はHTTPヘッダーを操作し、クライアントに対してキャッシュ制御を行う関数らしい。引数はマニュアルを読む限り次の4つ。
当関数を呼ばない場合、自動的にsession.cache_limiterに指定された値が適用されるらしい。そのデフォルト値は'nocache'であるらしい。従って、前掲のsession01.phpは当関数を呼んでいないため'nocache'が仮定され、結果として動作はsession02.phpと同等のものになっていたことが推測される。
session_cache_limiter()はHTTPヘッダーを操作する。従って、前掲のスクリプトの動作を解析するにはそのHTTPヘッダーを観察する必要がある。今回はFirefoxの拡張(extension)の一つであるLiveHTTPHeadersを用いて、前掲の三つのスクリプトにアクセスした場合のHTTPヘッダーを観察してみることにした。
session01 - 03.phpにアクセスする。いずれもアクセスの前にCookieとブラウザキャッシュを全クリアする。操作としては以下の手順で統一した。
このときのLiveHTTPHeadersのヘッダーを以下のファイルに保存した。
以下にログファイルを解析した結果を、要点を絞ってまとめる。
サーバーからの応答の内、Expires, Cache-Control, Pragma 辺りが怪しい。
HTTP/1.x 200 OK Date: Sat, 09 Jul 2005 15:03:38 GMT Server: Apache/2.0.50 (Win32) PHP/4.3.8 X-Powered-By: PHP/4.3.8 Set-Cookie: CacheExpireExperiment01=225b4c965b29028b78bec94bfebce4cb; path=/ Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0 Pragma: no-cache Keep-Alive: timeout=15, max=100 Connection: Keep-Alive Transfer-Encoding: chunked Content-Type: text/html; charset=EUC-JP
HTTP/1.x 200 OK Date: Sat, 09 Jul 2005 15:04:21 GMT Server: Apache/2.0.50 (Win32) PHP/4.3.8 X-Powered-By: PHP/4.3.8 Set-Cookie: CacheExpireExperiment02=56e20d4184fbe8690b9e1beb00d314e1; path=/ Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0 Pragma: no-cache Keep-Alive: timeout=15, max=99 Connection: Keep-Alive Transfer-Encoding: chunked Content-Type: text/html; charset=EUC-JP
HTTP/1.x 200 OK Date: Sat, 09 Jul 2005 15:04:51 GMT Server: Apache/2.0.50 (Win32) PHP/4.3.8 X-Powered-By: PHP/4.3.8 Set-Cookie: CacheExpireExperiment03=ef639f33f6259fea6437c5aa3255a429; path=/ Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: private, max-age=10800, pre-check=10800 Last-Modified: Sat, 09 Jul 2005 15:00:52 GMT Keep-Alive: timeout=15, max=100 Connection: Keep-Alive Transfer-Encoding: chunked Content-Type: text/html; charset=EUC-JP
まず、session01と02において、Expires/Cache-Control/Pragmaヘッダーフィールドが完全に一致している。これは前述のsession_cache_limiter()とsession.cache_limiterのデフォルト設定との関連より推測した、session01と02の動作が同じであることを証明している。
続いてsession03.phpとの比較だが、session03.phpで大きく異なるのが次の部分である。
Expires: Thu, 19 Nov 1981 08:52:00 GMT Cache-Control: private, max-age=10800, pre-check=10800 Last-Modified: Sat, 09 Jul 2005 15:00:52 GMT
Cache-Controlが no-cache ではなくなり、privateになっている。また幾つかのパラメータが追加されている。さらにLast-Modifiedフィールドが追加されている。ちなみに、session_cache_limiter()にprivate_no_expireを指定するとExpiresフィールドが無くなる。この点についてはPHPのマニュアルと一致している。
以上より、session_cache_limiter()に渡す値は Cache-Control, Pragma のヘッダーフィールドに影響を与える事が確認できた。ではこの二つ(およびExpires, Last-Modified)ヘッダーフィールドが、ブラウザにどういった影響を与えるのかをHTTPのRFC2616を元に調べてみる。
HTTPについての文献は次のURLに日本語で非常に詳しくまとめられている。以下、このHPを元に今回の調査対象となるヘッダーフィールドおよびブラウザのキャッシュ制御について著者の視点からまとめてみる。
注意 :日本語訳の使用上の注意点については必ず http://www.studyinghttp.net/translations#Notice を参照して下さい。あくまでも英語文書が正式版であり、日本語訳した場合の誤訳や誤解については無保証だそうです。
Webページがキャッシュされる場所はクライアントという視点では二カ所ある。
プロクシサーバーは一般に共用サーバーである。この事により、「個人のプライバシーに関わるページのキャッシュの是非」を操作できる必要性が生じる。これにより、キャッシュには主に以下の3段階の"レベル"が導入されるべきである。
さらに重要な点として、「キャッシュが無効化されるのをいつ、どのようにしてブラウザは認識するのか」という問題も出てくる。すなわち、
の二点を制御できて初めてキャッシュを安全に確実に利用できるようになる。
現在主なサーバー・ブラウザ(クライアントプログラム)が対応しているHTTPのバージョンは 1.1/1.0 の二種類。このうち、HTTP/1.0時代のキャッシュ制御ヘッダーフィールドについてまとめる。
Expires, Pragma ともにHTTP/1.0時代のフィールドだが、下位互換性のため使用している。HTTP/1.1に対応したプログラムの場合はCache-Controlヘッダーフィールドがこれらより優先して使用される。
近年の主要なサーバー・ブラウザが対応しているHTTPバージョン1.1では、Cache-Controlがキャッシュ制御用のヘッダーフィールドとして利用されている。
Last-Modifiedヘッダーは、Cache-Controlでno-cache以外が指定されてキャッシュが利用されるシーンにおいて、有効期限の判別材料として使われるらしい。。RFCではキャッシュの有効性チェック関連で大まかな規定はされているようだが、細かい部分は個々のプログラムに依ってしまうようだ。( http://www.studyinghttp.net/caching )
以上の調査より、以下の結論を導き出せる。
「ページの有効期限切れ」画面が・・・
つまるところ件の画面はIEのバグでも何でもなく、RFCに従った正常な動作であることが導き出せた。Firefoxの場合も、キャッシュがないため再度リクエストを送る動作なのでRFCから逸脱していない。ではいったいなぜ、標準に従っている筈のこの動作について多くのWebプログラマーが頭を悩ませるのか?また、巷間にあふれる処方箋はどこまでが正しいのか?
以下では、この二つの疑問について「そもそも論」と「PHPのソースコード」の二つの側面から追求し、もっとも効果的かつ抜本的な対策案「チケットの導入」を考える。
まずこの点について考えなければならない。なぜなら"「ページの有効期限切れ」画面を表示させたくない"という要求自体が無くなれば、そもそもこの対策に頭を悩ませる必要はなくなる。
ユーザーの立場に立てばいずれも至極まっとうな要求である。しかしWebプログラマはこれらの要求を聞くと眉をひそめる。なぜか?
つまり以下の二点が頭を悩ませる最大の要因である。
これが為に本来であれば意味的に至極まっとうな、IEの「ページの有効期限切れ」画面やFirefoxの「再POSTの確認」ダイアログを、ユーザーを戸惑わせたくないが為にどうにかして無効化する必要が出てくるのであろう。元々入力値や個人情報の安全性を補助するためのキャッシュ無効化機能が、そのクライアントプログラム側のインターフェイス故に、結果としてユーザーを戸惑わせる機能として否定されようとしているのだ。Webプログラマは、結果としてキャッシュ機能を有効化することにより元の入力フォームに"戻らせ"、代わりに再POST防止のため頭をひねることとなる。
ではいったい、キャッシュ機能を正しく有効にするにはどうすればよいのか?PHPに限定して、その処方箋をざっとGoogleで検索してみた結果を以下に列挙する。
どうやらsession_cache_limiter()に結局は落ち着くようである。関連するPHPマニュアルを以下に列挙する。
PHPソースコードの探索に入る。ソースコードのバージョンは4.3.11, ターゲットは ext/session/session.c のみである。
まずPHP関数を定義している"PHP_FUNCTION"マクロを使用した関数の中からsession_cache_limiter, session_cache_expireを探る。1331行目以降に出てくる。以下、適当に省略して要点のみを抜き出す。
// 現在のcache_limiterを返す。引数が渡されれば、引数でcache_limiterを更新する。 PHP_FUNCTION(session_cache_limiter) { zval **p_cache_limiter; char *old; zend_get_parameters_ex(ac, &p_cache_limiter); old = estrdup(PS(cache_limiter)); convert_to_string_ex(p_cache_limiter); zend_alter_ini_entry("session.cache_limiter", sizeof("session.cache_limiter"), Z_STRVAL_PP(p_cache_limiter), ...); RETVAL_STRING(old, 0); } // 現在のcache_expireを返す。引数が渡されれば、引数でcache_expireを更新する。 PHP_FUNCTION(session_cache_expire) { zval **p_cache_expire; long old; old = PS(cache_expire); zend_get_parameters_ex(ac, &p_cache_expire); convert_to_long_ex(p_cache_expire); PS(cache_expire) = Z_LVAL_PP(p_cache_expire); RETVAL_LONG(old); }
どうやらPS(cache_limiter), PS(cache_expire), session.cache_limiter辺りが怪しいようです。ただ、なぜcache_limiterではiniエントリを更新していてexpireの方ではそれをしないかは不明です。
147行目でこの二つがiniファイルより初期化されています。
STD_PHP_INI_ENTRY("session.cache_limiter", "nocache", PHP_INI_ALL, OnUpdateString, cache_limiter, ...) STD_PHP_INI_ENTRY("session.cache_expire", "180", PHP_INI_ALL, OnUpdateInt, cache_expire, ...)
このマクロの詳細は不明ですが、デフォルト値とおぼしき値はマニュアルと一致しています。
PS(cache_limiter)は前述のPHP_FUNCTION(session_cache_limiter)および822行から始まる
static int php_session_cache_limiter(TSRMLS_D)
内でしか使われていません。
PS(cache_expire)は770行目
CACHE_LIMITER_FUNC(public)
および789行目
CACHE_LIMITER_FUNC(private_no_expire)
の二カ所で使われています。
ざっとみてだいぶ本命に近づいてきたようです。cache_expireは秒数として使われているようです。cache_limiterを探ると本命に近づけそうです。
PS(cache_limiter)が使われているphp_session_cache_limiter()について探索してみます。
// 238行目:session_cache_limiter()の各引数と対応する関数のマッピング用構造体の定義 typedef struct { char *name; void (*func)(TSRMLS_D); } php_session_cache_limiter_t; // 814行目:実際の構造体の実体 static php_session_cache_limiter_t php_session_cache_limiters[] = { CACHE_LIMITER_ENTRY(public) CACHE_LIMITER_ENTRY(private) CACHE_LIMITER_ENTRY(private_no_expire) CACHE_LIMITER_ENTRY(nocache) {0} }; // 822行目:php_session_cache_limiter()の要点のみ抜粋 static int php_session_cache_limiter(TSRMLS_D) { php_session_cache_limiter_t *lim; // 構造体の中から、現在のcache_limiterに一致するのを取り出し、対応付けされている関数を実行する。 for (lim = php_session_cache_limiters; lim->name; lim++) { if (!strcasecmp(lim->name, PS(cache_limiter))) { lim->func(TSRMLS_C); return 0; } } return -1; }
php_session_cache_limiter()自体はsession_start()内で php_session_reset_id(TSRMLS_C) の後に呼ばれています(1097行目)。
確信まであと少しです。構造体に使われているマクロで注目してみると、構造体の実体のすぐ上にCACHE_LIMITER_FUNCと言う一連の関数が定義されていました。ヘッダーフィールドらしき文字列も見えます。
この一連の関数こそが、Expires/Pragma/Cache-Control/Last-Modifierをセッション使用時に制御している中枢です。実体は751行目のlast_modifiedから始まり813行目まで続きますが、要はどの関数がどのヘッダーフィールドを送信しているのか分かればよいので、そこだけまとめました。
ようやく追いつめました。 これらが、session_cache_limiter()により呼び出されるキャッシュ制御ヘッダーフィールドを送出している部分です。これらはphp_session_cache_limiter()内で、現在のcache_limiterから呼び出されます。
Webを調べていると、session_cache_limiter()について次の例がそれぞれ別の場所で見つかった。
session_cache_limiter('none'); session_cache_limiter('no-cache'); session_cache_limiter('private, must-revalidate');
これまでの調査によると、これらの引数は明らかに無効である。しかし掲載場所ではいずれも「これで思った通りに件の画面が表示されなくなった。」と報告されていた。
この理由は簡単で、php_session_cache_limiter()では引数(つまり現在のcache_limiter)にヒットしなかった場合はそのままスルーしている。スルーするとどうなるか。単にキャッシュ制御ヘッダーフィールドが送出されなくなるだけである。そうなると、只のHTMLと同様に普通にブラウザにキャッシュされ、結果、「ページの有効期限切れ」画面はブラウザバックでは表示されなくなる。
これが今まで明らかにされていなかった理由としては、session_cache_limiter()に指定した値が有効かどうか判別するためのインターフェイスが無いことが考えられる。それに加え、関数名からCache-Controlヘッダーに指定するものを渡せばよいと誤解を招いた可能性もある。前掲の2・3番目の間違いがそれにあたると思われる。
以上より、今回の件に関する動作はほぼ押さえられた。結論としては「ページの有効期限切れ」画面を表示させない対策としてはsession_cache_limiter('private_no_expire')をコールすることが最大公約数として導き出せる。(*3)
しかし、再POSTの危険性に対してはどうすればよいのか?多くが試行錯誤中ではあり、たとえばDBにレコードを挿入・更新する場面ではUNIQUE違反を検出する場合がある。しかし、この場合UNIQUEキーが無く単純にシーケンスと関連づけされたPrimary Keyだけが有効となる場面ではUNIQUEを検出できない。
一つの手法としては、都度ユニークなキー値を生成し、hiddenとしてフォームに埋め込んでおく「チケット」の導入が考えられる。アプリケーションは裏側でチケットを管理し、アクションが発生すればチケットに「使用済み」マークを付ける。もしも使用済みマークのついたチケットがPOSTされてきたら、エラーで弾く。Javaでの開発では導入される場合があるようだが、これが比較的安全であろう。
あるいは、そもそも個人情報をキャッシュさせないためのキャッシュ制御であるのだから、ユーザーに「そういうもの」として受け止めて頂くよう説得し、「ページの有効期限切れ」画面が表示されることに対して理解を求める、という手もある。