URL中のPath部分のパーセントエンコード

URLのPath部分でURLエンコードした文字列を扱いたい!

Apacheの裏でPassenger動かしている環境において、パス部分にパーセントエンコードされた文字列を含む、たとえば以下のようなURLを扱おうとすると、問題に直面する。

http://example.com/foo/http%3A%2F%2Fexample.org%2Fbar/baz

何がおこるかというと、Railsにわたる前にApacheが勝手にパーセントデコードしてしまうため、
Railsのroutes.rbで受ける段階で、たとえば以下のような記述をしてもちゃんと意図した形でマッチがなされないためだ。

match '/foo/:url/baz' => ..

この問題の解決のため(解決にはなっていないのだが)、泣く泣くサービスの外部仕様を捻じ曲げて、扱うURLの中で、以下のようにパーセントエンコードを2重でかけるという手段を使ってきた。

http://example.com/foo/http%253A%252F%252Fexample.org%252Fbar/baz

こうすることで、Apacheの野郎が余計なお世話としてパーセントデコードを1回してくれやがったとしても、Rails にはパーセントエンコードされた文字列(たとえば上記の例でいえば:urlのところにhttp%3A%2F%2Fexample.org%2Fbarという文字列)がわたってくるので、これをアプリ側でデコードすることで、意図した機能をなんとか実現することはできていた。ただしこのやり方はサービスの外部仕様であるURLが無駄に長く汚くなるという問題が残る。

ApacheからNginx に乗り換えたら・・・解決するかとおもったが、しなかった。

で、先日WebサーバをApacheからnginxに置き換えたところ、同じURLの使い方(2重エンコード)のリクエストで、Railsに2重エンコードされたままの文字列が返ってくるようになった。NGINXは余計なお世話のデコードをしないのかと思いと一瞬期待したのだが、結局期待ははずれた。

何がおこったかというと、Nginxは2重エンコードされた部分はそのまま放置するくせに、ふつうに1回パーセントエンコードされたパスはApache同様余計なデコードをしやがってくれた。すなわち、%25は%にデコードせずそのまま%25で渡してくるのに、%2Fはしっかり/にデコードしやがるのだ。

URLエンコードってそもそもRFC的にどうなのさ?

Apacheといい、NGINXといい、なんでこういう余計なお世話をしやがってくれるのか? 気になりRFCをあさってみた。すなわち「これバグじゃねぇ?」と言いたいのだが、そもそもURL中、Path部分でパーセントエンコードされた文字をどういう風に扱うべきか、仕様をしらないと自信をもって「バグだ」といえないからである。

まず、URLってのは今はRFC3986に従うのが正しいようだ。
http://d.hatena.ne.jp/keisukefukuda/20080321/p1



ApacheにしろNGINXにしろ、あの実装は「Path部分でPercent-Encodeされた文字列は、サーバが勝手にデコードしてしまっていい」と主張しているのと等しい。
その主張が正当性を得るためのシナリオとして「URL正規化で結局エンコード前と同等URLとみなされるべきだから」というのが想像されたので、僕はそうじゃないことを確認したいわけだ。つまり僕の関心事は、section6.2のあたりになる。

で、見つけたのが、6.2.2.2の記述

   In addition to the case
   normalization issue noted above, some URI producers percent-encode
   octets that do not require percent-encoding, resulting in URIs that
   are equivalent to their non-encoded counterparts.  These URIs should
   be normalized by decoding any percent-encoded octet that corresponds
   to an unreserved character, as described in Section 2.3.

http://tools.ietf.org/html/rfc3986#section-6.2.2.2


本来Percent Encodeの必要のないunreserved characterをPercent EncodeするURI生成機構があるということと、そういうURI生成機構で生成されたunreserved characterに対応するpercent-encodeされたオクテットを含むURLは、その部分をデコードする形で正規化されるということが書いてある。

すなわち(ちゃんとそうは書いていないが)ここから読み取れるのは、unreserved でないreservedなcharacterをPercent EncodeしたURLは、正規化しても元のURLと同等にはならないということだ。よし! そしてunreserved characterとは以下の定義である。

unreserved  = ALPHA / DIGIT / "-" / "." / "_" / "~"

http://tools.ietf.org/html/rfc3986#section-2.3

つまり/や:はreservedなわけで、percent-encodeされたものとencode前のものは正規化しても別URLになるということだ。例を挙げると、以下において1と2は同じだが、2と3は違うURLとして扱うべきということとなる。

1. http://example.com/foo/http%3A%2F%2Fexample%2Eorg%2Fbar/baz
2. http://example.com/foo/http%3A%2F%2Fexample.org%2Fbar/baz
3. http://example.com/foo/http:/example.org/bar/baz

ということで、やっぱりApacheもNginxも勝手にReserved characterをデコードするのはやめてほしいもんだし、
そういう動きは「バグだ!」といっちゃっていいんだと思う。

URLのパス部分に使っていい文字ってなんだろう?

一応念のためURLのパス部分ってどういう文字を使ってよいものなのか、rfc3986から関連するsyntax定義をひろってみた。

      URI         = scheme ":" hier-part [ "?" query ] [ "#" fragment ]

      hier-part   = "//" authority path-abempty
                  / path-absolute
                  / path-rootless
                  / path-empty
  scheme      = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )
   authority   = [ userinfo "@" ] host [ ":" port ]
    path          = path-abempty    ; begins with "/" or is empty
                    / path-absolute   ; begins with "/" but not "//"
                    / path-noscheme   ; begins with a non-colon segment
                    / path-rootless   ; begins with a segment
                    / path-empty      ; zero characters

      path-abempty  = *( "/" segment )
      path-absolute = "/" [ segment-nz *( "/" segment ) ]
      path-noscheme = segment-nz-nc *( "/" segment )
      path-rootless = segment-nz *( "/" segment )
      path-empty    = 0

      segment       = *pchar
      segment-nz    = 1*pchar
      segment-nz-nc = 1*( unreserved / pct-encoded / sub-delims / "@" )
                    ; non-zero-length segment without any colon ":"

      pchar         = unreserved / pct-encoded / sub-delims / ":" / "@"
   pct-encoded   = "%" HEXDIG HEXDIG

   unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
   reserved      = gen-delims / sub-delims
   gen-delims    = ":" / "/" / "?" / "#" / "[" / "]" / "@"
   sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
                 / "*" / "+" / "," / ";" / "="

なるほど。面白い。パスの部分に:とか@とか=使っていいんだね。そうすると、以下のような変態的URLも「あり」ってこと。

http://example.com/unreserved-._~/percentenc%2527%3A%2F/subdelims!$&'()*+,;=/pchar:@/

 (あー・・ hatena diaryさん、丸カッコのところで自動リンク切っちゃったね。残念!ハズレ〜!)

で、先ほどの議論のように、Percent-encodingはunreservedな文字はする必要がなくて、もしやっちゃったらURL正規化ではもとに戻したものとして評価すべきということなので、上記変態URLと比べると

同じ : http://example.com/unreserved%2d%2E%5F%7e/./percentenc%2527%3A%2F//subdelims!$&'()*+,;=/pchar:@/
違う : http://example.com/unreserved-._~/percentenc%252527%3A%2F/subdelims!$&'()*+,;=/pchar:@/

ってなるわけですな。。大体理解できた気がする! 満足!