mod_rewriteの無限ループでIS_SUBREQ REQUEST_FILENAME LA-U:REQUEST_FILENAME ENVの変化を追ってみた

公式ドキュメントで、rewriteの技術で最もトリッキーでありながら、最初に理解しておくべきことが解説されています。

The internal processing of this module is very complex but needs to be explained once even to the average user to avoid common mistakes and to let you exploit its full functionality.

http://httpd.apache.org/docs/2.2/en/rewrite/rewrite_tech.html#Internal

mod_rewriteの内部プロセスはとても複雑ですが、普通のユーザーにとっても、よくある間違いを防ぎ、mod_rewriteの全機能を理解するためには、一度は説明しておく必要があるでしょう。だそうで。技術情報を調べるときには、ついリファレンスをあさってしまいますが、mod_rewriteについていえば、この理解は確かに重要だと思いました。

To overcome this chicken and egg problem mod_rewrite uses a trick: When you manipulate a URL/filename in per-directory context mod_rewrite first rewrites the filename back to its corresponding URL (which is usually impossible, but see the RewriteBase directive below for the trick to achieve this) and then initiates a new internal sub-request with the new URL. This restarts processing of the API phases.

http://httpd.apache.org/docs/2.2/en/rewrite/rewrite_tech.html#InternalAPI

ディレクトリコンテキストにおいて、mod_rewriteは解決済みのファイル名をURLに戻し、内部サブリクエストを発生させてAPIフェーズからやりなおします。という、この内容。これによって、サーバー設定ファイルやvirutalhostでは動作する記述が内や.htaccessに書くと無限ループしてしまう理由になっているようです。
また、この書き換えはコストが高いのでサーバー設定ファイルを扱えるのであれば、.htaccessを使った処理にするべきではないとまで書かれているようです。
前記事mod_rewriteの基本を再確認 - noopな日々で自分の設定を例にして確認しましたが、このドキュメントは斜め読みしていて、きっちり理解していませんでした。(汗
ということで、どんなもんか動作を見ないと気が済まない方なので、意図的に無限ループさせて、各種変数がどんな変化をするかを見てみました。

無限ループ設定

シナリオ

背景としては、ホストのオーナーはtest以下の管理者に処理を任せる。testの管理者はtest配下に実ファイルがあればそれを使うといったことを自主設定できるというケース

;virtualhost
;すべてのリクエストをtest配下で処理するように設定
RewriteEngine On
RewriteRule ^ /test/%{REQUEST_FILENAME} [NC,NS,L]
;/test/.htaccess
;.htaccessですべての処理をindex.phpで処理するように設定
;必要に応じてrewriteCondを追加する
RewriteEngine On
RewriteRule ^ /test/index.php [NC,NS,L]

これで、無限ループさせることができます。普通はこんなことはしないと思いますが、サーバーコンテキストとディレクトリコンテキストの合わせ技で発生するところが面白いかなと。極単純にみれば、すべて/test/index.phpで処理できてもよさそうなんですが、ここにサブリクエストのマジックがありそうです。

自問自答

Q: /test/index.phpが実在してるのに、/test/index.phpに書き換えたときなぜすぐに処理できないのでしょうか?
A: mod_rewriteが書き換えるのはURLです。/test/index.phpというURLがファイルとしてのindex.phpであるかどうかを確認するために、mod_rewriteは内部サブリクエストで結果を取得するしかありません。

.htaccessに書いた内容がvirtualhost内に書いてあれば無限ループしません。ポイントは、.htaccessの設定で/test/index.phpに書き換えたときに、サブリクエストとなるところです。
では、.htaccessだけを使っている時になぜ無限ループしないのでしょうか。RewriteRule適用時にサブリクエストと一致している条件は再投函しないとかそういうルールで保護されているんだと想像します。おそらく、この隠されたロジックが、サブリクエストを意識させない部分、さらには各種変数の違いで挙動が分からなくなる原因になっているような気がします。

サブリクエストを分かりやすく解析してみる

RewriteLogとRewriteLogLevelを正しく設定すれば、rewriteのログを確認して多くの情報が得られるはずなのです。たとえば、こちら=>続・RewriteとDirectoryIndexで悩む… : おまえのログが、なぜだか手元のいくつかの環境ではログファイルは育成されるものの、肝心のログが吐かれません。理由はよくわかりません。(汗
そこで、泥臭い方法ですが、無限ループを回避させてブラウザで表示するようにしてみます。
前記事:mod_rewriteの基本を再確認 - noopな日々でサブリクエスト前にリクエストヘッダをセットすることで、サブリクエストかどうかをチェックしました。しかし、他の方法でできないでしょうか。確認してみます。

  • 設定した環境変数はサブリクエスト内で使えるか
  • %{IS_SUBREQ}はtrueになるのか。
  • %{REQUEST_FILENAME}と%{LA-U:REQUEST_FILENAME}はどうなるか。

無限ループ設定を分岐させる

設定を少し変えて下記のようにしてみました。.htaccessに入るとリクエストヘッダをセットしてこれを元にループを回避しています。

;virtualhost
;すべてのリクエストをtest配下で処理するように設定
RewriteEngine On

RewriteCond %{HTTP:XYZABC} !1
;(1)
RewriteRule ^ - [C,E=FOO:1]
RewriteRule ^ /test/%{REQUEST_FILENAME}?RF[1]=%{LA-U:REQUEST_FILENAME}&IS_SUBREQ[1]=%{IS_SUBREQ}&ENV[1]=%{ENV:FOO} [NE,NC,NS,L,QSA] 
;(2)
RewriteRule ^ /test/index.php?RF[3]=%{LA-U:REQUEST_FILENAME}&IS_SUBREQ[3]=%{IS_SUBREQ}&ENV[3]=%{ENV:FOO} [NE,NC,QSA,NS,L] 
;/test/.htaccess
;.htaccessですべての処理をindex.phpで処理するように設定
RequestHeader set XYZABC 1
RewriteEngine On

RewriteRule ^ /test/index.php?RF[2]=%{LA-U:REQUEST_FILENAME}&IS_SUBREQ[2]=%{IS_SUBREQ}&ENV[2]=%{ENV:FOO} [NE,NC,QSA,NS,L] 

これで、

  1. オリジナルリクエス
  2. /test/以下へ書き換え (virtualhost内のrewriteRule(1)) URL-to-filenameフック
  3. ファイルベースでtest以下を探索
  4. index.phpに書き換え (.htaccess内rewriteRule)fixup フック
  5. サブリクエス
  6. /test/index.phpへ書き換え (virtualhost内のrewriteRule(2))URL-to-filenameフック

という流れになります。
あとは、受け取ったスクリプトでURL変数を展開して表示するだけです。

結果

最初のRewriteで設定した環境変数は、サブリクエスト段階で消えていました。また、IS_SUBREQは常にfalseでしたので、rewriteによるサブリクエストかどうかを判定するのには使えませんでした。IS_SUBREQがたまたま私の環境で使えないのか、mod_rewriteのサブリクエストはIS_SUBREQの対象ではないのか、その辺は定かではありません。

%{REQUEST_FILENAME}と%{LA-U:REQUEST_FILENAME}の違い

%{LA-U:REQUEST_FILENAME}に関する説明は、

5 %{LA-U:variable} can be used for look-aheads which perform an internal (URL-based) sub-request to determine the final value of variable. This can be used to access variable for rewriting which is not available at the current stage, but will be set in a later phase.

For instance, to rewrite according to the REMOTE_USER variable from within the per-server context (httpd.conf file) you must use %{LA-U:REMOTE_USER} - this variable is set by the authorization phases, which come after the URL translation phase (during which mod_rewrite operates).

On the other hand, because mod_rewrite implements its per-directory context (.htaccess file) via the Fixup phase of the API and because the authorization phases come before this phase, you just can use %{REMOTE_USER} in that context.
6 %{LA-F:variable} can be used to perform an internal (filename-based) sub-request, to determine the final value of variable. Most of the time, this is the same as LA-U above.

http://httpd.apache.org/docs/2.2/mod/mod_rewrite.html#rewritecond

遅延評価的に変換先のファイルパスを取得できるようだというのはわかっても実際にどうふるまうのかは確かめてみてよくわかりました。ディレクトリコンテキストでのURLの解決はサブリクエストで行われましたが、%{LA-U:REQUEST_FILENAME}に関して言えば、URL-to-filenameを抜ける段階でのパスがセットされるようです。サブリクエストだとすると、書き換え-書き換えの後のパスが入ってきてもおかしくないのですが、それだと内部で無限ループしそうです。上記のテストの結果としては、virtualhost内で取得できる%{LA-U:REQUEST_FILENAME}はvirtualhost内のrewriteRule適用後のパスをフルパス化したものであり、.htaccess内での%{LA-U:REQUEST_FILENAME}もまた、.htaccess内のRewriteRule適用後のパスでした。
一方、%{REQUEST_FILENAME}はディレクトリコンテキスト.htacess内では、URL-to-filename処理後のフルパスであるのに対し、virtualhost内での%{REQUEST_FILENAME}はリクエストされたURLのままでした。

  • %{REQUEST_FILENAME}
    • サーバーコンテキスト内(virtualhost)
    • ディレクトリコンテキスト内(もしくは.htaccess)
      • URL-to-filename処理後にエンジンに渡されたパス
  • %{LA-U:REQUEST_FILENAME}
    • サーバーコンテキスト内(virtualhost)
      • サーバーコンテキストを抜け出す際、URL-to-filename後の物理パスが遅延評価的にセットされる。(先に取得できる)
    • ディレクトリコンテキスト内(もしくは.htaccess)
      • ディレクトリコンテキストを抜ける際のfixup後の物理パスが遅延評価的にセットされる。(先に取得できる)

この違いがあるため、.htaccess内では%{REQUEST_FILENAME}で検査対象の物理パスを取得できるが、virtualhost内では、%{DOCUMENT_ROOT}を前に付けるといったことが行われるわけですね。
なんとなく、%{LA-U:REQUEST_FILENAME}が%{DOCUMENT_ROOT}/%{REQUEST_FILENAME}の代わりになりそうな気がするかもしれませんが、パスが書き換えられる可能性があるときは別の値になるので、ファイルの存在確認等では、前者は使えませんね。
LA-Uのギミックはよくわかりませんが、もう少し深い何かがある気がします。

Tフラグが効かない

もうひとつ、RewriteRuleのTフラグが引き継がれないようです。本当の意味でのサブリクエストであれば、処理後は戻ってきてやり直すという呼び出し関係になると思いますが、それは重い処理になるので、サブリクエストというよりもリダイレクトしているのかもしれません。そのためか、最後の書き換えが完了するときにTフラグでapplication/x-httpd-phpを設定しても動きませんでした。Tフラグはサブリクエストの最終段階の書き換えで付けないと意味がなさそうです。
エントリポイントだけでPHPが動けばよいのであれば、AddTypeせず、TフラグでPHPとして動作させるようにします。転送がすべて完了するところで、

RewriteCond %{REQUEST_FILENAME} ^/path/to/index.php
RewriteRule ^. - [T=application/x-httpd-php]

といった感じで指定する方がよさそうです。これなら、index.phpでなくてもよいですね。AddTypeしないことによる保護は気休め程度ですが、2重拡張子などで、Mime設定漏れ時にPHPCGIとして実行されてしまうといった事故を少しはカバーできると思います。

%{IS_SUBREQ}の使いどころ、DirectoryIndex

上記したようにディレクトリコンテキストでrewriteが仕事を終えたときに発するsubrequestに対しては%{IS_SUBREQ}はtrueになりませんでした。これはもしかすると仕様的にはバグのような気がしてなりませんが、それはおいておいて、少なくともDirectoryIndexによるサブリクエスト時にはtrueになるようです。
DocumentRootの階層を調整したときは、ルートディレクトリに対するrewriteが意図したとおりに動かないことがありますが、これはDirectoryIndexによるサブリクエストがrewriteエンジンと競合するのが原因だろうと思います。この現象を回避するのに%{IS_SUBREQ}は使えそうです。

RewriteCond %{IS_SUBREQ} true
RewriteRule . /path/to/handler.php [L]

これで、DirectoryIndexの動作下で正しく動作させにくい問題を回避できます。