URLのパス部分に%2Fを含む時の扱い

お知り合いのページを見て初めて知ったのですが、Apacheは標準ではパス部分に %2F(/をエスケープしたもの) を含むURLに対して404を返すそうです。

このままだと、最近流行り(?)の "/" でパラメータを区切る場合に、"/" 自体を値に含めることができません。そこでApacheの設定で、AllowEncodedSlashes を On にすると、URL中に %2F も使用できるようになります。

というのがメインな部分なのですが、その中で、

ところで、RFCとか規則上って、「/」をURL encodeして「%2F」にしても良いのか?
それともしてはいけないのか。これがよく分からん。

AllowEncodedSlashes ディレクティブにはまる。

という疑問が書かれていたので、RFCでどうなっているのかちょっとだけ調べてみました。

3.2.3 URI Comparison

Characters other than those in the "reserved" and "unsafe" sets (see
RFC 2396 [42]) are equivalent to their ""%" HEX HEX" encoding.

RFC2616 Hypertext Transfer Protocol -- HTTP/1.1

URIの "reserved" と "unsafe" 以外は、エスケープされたものと等しく扱うみたいです。
そしてRFCでのURIの定義はこんな感じです。

reserved = ";" | "/" | "?" | ":" | "@" | "&" | "=" | "+" | "$" | ","

RFC2396 Uniform Resource Identifiers (URI): Generic Syntax

これを見ると "/" は "reserved" に含まれていますね*1。これらを単純に解釈すると、"/" と %2F は等しくは扱われないのだと思います。

RFC2616の先ほどの行の後に具体的な例が書かれています。

For example, the following three URIs are equivalent:

http://abc.com:80/~smith/home.html
http://ABC.com/%7Esmith/home.html
http://ABC.com:/%7esmith/home.html

RFC2616 Hypertext Transfer Protocol -- HTTP/1.1

これは ~ が "reserved" に含まれていないので、等しく扱うということだと思います。
一方、"/" は "reserved" に含まれているので、以下の二つは等しくないはずです。

      http://abc.com/hoge/hoge.html
      http://abc.com/hoge%2Fhoge.html

ざっと見た限りでは、これくらいしか記述がありませんでした。これらの部分は、等しいかどうかだけですので、サーバの振る舞いまでは制約を受けないのかもしれませんが、Apacheが "/" と "%2F" を同じに扱うのはRFC的に微妙な気もしますね。

PATH_INFOの仕様

さて、これでApache自身がファイルを扱う場合は分かったのですが、もう1点気になった部分があります。

PATH_INFOが/で渡されたか、%2fで渡されたか判断がつかなくなる。まぁ、PATH_INFOは変な値を持ってることが多いので(つか、勝手に正規化されていたりする)使わないようにしているので、問題なし(ぉぃ

AllowEncodedSlashes ディレクティブにはまる。

なんでそんな不便な動作なんだろう? と思って AllowEncodedSlashes のドキュメントを見てみると、

符号化されたスラッシュを許可することは、復号をすることを 意味しません。%2F や (関係するシステムでの) %5C は、他の部分が復号された URL の中でもそのままの形式で 残されます。

http://httpd.apache.org/docs/2.0/ja/mod/core.html#allowencodedslashes

あれ、%2F はそのまま残るって書かれていますね。どういうことかと調べてみると、該当するバグが bugzillaに登録されてました。

%2F will be decoded in PATH_INFO (Documentation to AllowEncodedSlashes says no decoding will be done)

https://issues.apache.org/bugzilla/show_bug.cgi?id=35256

本来は %2F を残すために ap_unescape_url_keep2f() という関数でデコードしてるんだけど、実際は %2F を含めて全部デコードしちゃうらしいです。(関数名に keep2f って付いてるのに…)

2.0.47では大丈夫だったけど、2.0.52ではダメだったと書かれています。何かのタイミングで関数を書き換えちゃったのでしょうか?

ちなみに、

this bug exists in 2.2.6 as well

https://issues.apache.org/bugzilla/show_bug.cgi?id=35256

とあるので、まだ治ってないみたいです。とありますが、追記2に書いたようにtrunkでは修正されてます。もっとも、今更変更しても混乱するだけなので、%2Fもデコードしてしまうのが実質的な仕様になるのでしょうね。

というわけで結論としては、やっぱりPATH_INFOで "/" と "%2F" を区別する方法はないみたいです。

実際Googleしてみると、PATH_INFO は怪しいので REQUEST_URI を使いましょう、という記事がいっぱい出てきます。みんな PATH_INFO は使っていないので、バグも気にならないのかもしれませんね。

(追記)PATH_INFOが壊れたのはいつ?

2.0.47では大丈夫だったけど、2.0.52ではダメだったと書かれています。何かのタイミングで関数を書き換えちゃったのでしょうか?

と書いた後に少し気になったので、実際にソースコードの変更履歴を調べてみました。
まず現在のコードになった場所はここのようです。

handling of encoded non-slashes was borked in the
AllowEncodedSlashes path

thanks to FirstBill for pointing that out!

http://svn.apache.org/viewvc/httpd/httpd/trunk/server/util.c?view=log#rev104937

どうやら既存コードだと壊れてしまうので、修正したってことみたいです。
そして肝心の修正内容はこんな感じです。

                 decoded = x2c(y + 1);
-                if (!IS_SLASH(decoded)) {
-                    *x++ = *y++;
-                    *x = *y;
+                if (decoded == '\0') {
+                    badpath = 1;
                 }
                 else {
                     *x = decoded;
                     y += 2;
-                    if (decoded == '\0') {
-                        badpath = 1;
-                    }
                 }

元々はIS_SLASHでデコード結果が / かどうかを判定していたようですが、バグ修正の際に勢い余って消してしまったような感じですね。

さて、この修正の付近をよく見てみると、実は前日にも同じ場所が変更されていました。

Fix the handling of URIs containing %2F when AllowEncodedSlashes is enabled.
Previously, such urls would still be rejected with 404.

http://svn.apache.org/viewvc/httpd/httpd/trunk/server/util.c?view=log#rev104925

この修正前までは、AllowEncodedSlashesオプションを指定しても、実際には動作していなかったはずだ、と書かれています。そして実際の修正内容はこんな感じです。

                 char decoded;
                 decoded = x2c(y + 1);
-                if (IS_SLASH(decoded)) {
+                if (!IS_SLASH(decoded)) {
                     *x++ = *y++;
                     *x = *y;
                 }

IS_SLASHの条件判定が逆になっています。

ところがこの部分は、デコード結果が / だった場合に、デコード前の文字(%2F)の方を採用するルーチンのようです。そのため、この条件判定を逆にしてしまうと、全部の文字がデコードされなくなります。

ということで結論としては、この時の修正に失敗したものを、翌日に再修正してみたら別のバグが混入した、という悲しい理由だったみたいです。修正日は2004年の9月ですので、ずいぶんと昔から今の動作になっていたようです。

ちなみに元々のコードですが、デコード前の文字列が(%2Fのように)3文字あるはずなのに、2文字しかコピーしていません。本来は下記のように修正するべきだったのだと思います。

                 if (IS_SLASH(decoded)) {
                     *x++ = *y++;
+                    *x++ = *y++;
                     *x = *y;
                 }

ただロジック的には、最初の % さえコピーしてしまえば、2文字でも3文字でも別に問題ないコードに見えます。わざわざ修正したということは、何か問題が発生する条件があったのでしょうかね?

ちなみにbugzillaに投稿されているパッチの一つは、上記のコードと同じ内容になっています。

(追記2)修正されてた

よく見たら2007年の9月に修正されてたみたいです。unescapeが一つの関数にまとまってスッキリした作りになっています。それとreservedな文字列を無視できるようになっていて、ちゃんとRFCに書かれた通りに動作可能になったみたいです*2

時期的にはbugzillaで議論してる最中に既に直ってたみたいですが、その後にも症状が出るという人がいるところを見ると、ディストリビューションによっては含まれていなかったりするのかもしれませんね。

*1:ちなみに "unsafe" の定義は見当たりませんでした。

*2:実際の動作は呼び出し側でどのように使っているかに依存しますが