クエリパラメータのデリミタに ; を使うこともできる

本記事は、はてなエンジニア Advent Calendar 2020 の 18 日目の記事です。昨日は id:YaaMaa さんでした。

yaamaa-memo.hatenablog.com

社内チャットではこの話で盛り上がったときにトライ木も作られており、良い頭の体操になっていました。


さて、本題。

Hatena::Let を眺めていて、こんな URL に気づいた。

http://let.st-hatelabo.com/onk/let.iframe?code_id=g5G0uOeEqfcA;key=

クエリパラメータにセミコロン……!

パッと考えるとこれは

{
  code_id => "g5G0uOeEqfcA;key="
}

となりそうで、というか Ruby で実際にパースするとそうなる。

uri = URI("http://let.st-hatelabo.com/onk/let.iframe?code_id=g5G0uOeEqfcA;key=")
URI.decode_www_form(uri.query)
#=> [["code_id", "g5G0uOeEqfcA;key="]]

「こんなカッコ良くもない URL で独自規格作らないでよ」が最初の感想だったんだけど、ソースコードを読むと普通に $r->req->param('code_id')$r->req->param('key') で取得している。

param の実装である Plack::Request::_query_parameters を見に行くと

sub _query_parameters {
    my $self = shift;
    $self->env->{'plack.request.query_parameters'} ||= parse_urlencoded_arrayref($self->env->{'QUERY_STRING'});
}

となっていて、WWW::Form::UrlEncoded::parse_urlencoded_arrayref がパーサ。パーサの実装は

if ( src[i] == '&' || src[i] == ';') {

https://github.com/kazeburo/WWW-Form-UrlEncoded-XS/blob/0.26/lib/WWW/Form/UrlEncoded/XS.xs#L246

&; の両方をクエリパラメータのデリミタとしているし、テストコードにも含まれている!

'a=b&c=d'     => ["a","b","c","d"]
'a=b;c=d'     => ["a","b","c","d"]
'a=1&b=2;c=3' => ["a","1","b","2","c","3"]
'a==b&c==d'   => ["a","=b","c","=d"]
'a=b& c=d'    => ["a","b","c","d"]
'a=b; c=d'    => ["a","b","c","d"]
'a=b; c =d'   => ["a","b","c ","d"]
'a=b;c= d '   => ["a","b","c"," d "]
'a=b&+c=d'    => ["a","b"," c","d"]
'a=b&+c+=d'   => ["a","b"," c ","d"]
'a=b&c=+d+'   => ["a","b","c"," d "]
'a=b&%20c=d'  => ["a","b"," c","d"]
'a=b&%20c%20=d' => ["a","b"," c ","d"]
'a=b&c=%20d%20' => ["a","b","c"," d "]
'a&c=d'       => ["a","","c","d"]
'a=b&=d'      => ["a","b","","d"]
'a=b&='       => ["a","b","",""]
'a=&'         => ["a","","",""]
'&'           => ["","","",""]
'='           => ["",""]
''            => []

https://github.com/kazeburo/WWW-Form-UrlEncoded-XS/blob/0.26/t/01_parse.t#L26

ふえぇぇって思ってググるQUERY_STRING 中のパラメータの区切りは必ずしも '&' ではない - 理系学生日記 に行き着いて、

We recommend that HTTP server implementors, and in particular, CGI implementors support the use of ";" in place of "&" to save authors the trouble of escaping "&" characters in this manner.

https://www.w3.org/TR/1999/REC-html401-19991224/appendix/notes.html#h-B.2.2

という記述を見つけた。

えーーじゃあ Ruby でさっきダメだったのは何!!!って話になったのでまず Rack を見に行ったら id:Nyoho さんが 2015 年に PR 出していてW3C の同じ記述を参照しながら

Fix to use semicolons as separators for GET not for POST.

と GET リクエストのときのパース時のみ ; でも分割できるように修正している。当時 tDiary にも Issue が上がっていて

http://www.tamoot.net/d/?year=2015;month=1Q;category=Ruby

という URL がパースできなくなったトノコト。なるほど、tDiary にもセミコロン区切りがあったのね。CGI 時代の歴史的経緯っぽい香りがする。

閑話休題。「Rack は通すんじゃん」って思いつつ URI.decode_www_form の実装を見に行くと

# This refers http://url.spec.whatwg.org/#concept-urlencoded-parser,
# so this supports only &-separator, and doesn't support ;-separator.

https://github.com/ruby/ruby/blob/v2_7_2/lib/uri/common.rb#L443-L444

とわざわざ「; は対応しないよ」ってコメントが書いてある。

更に blame すると

lib/uri/common.rb (URI.decode_www_form): follow current URL Standard.

- # This refers http://www.w3.org/TR/html5/forms.html#url-encoded-form-data
+ # This refers http://url.spec.whatwg.org/#concept-urlencoded-parser ,
+ # so this supports only &-separator, don't support ;-separator.

https://github.com/ruby/ruby/commit/4a50d44

とのことで、 WHATWGapplication/x-www-form-urlencoded の仕様に則ったらしい。

http://url.spec.whatwg.org/#concept-urlencoded-parser

こちらでは & のみがデリミタと定義されている。

というわけで、application/x-www-form-urlencoded で POST したときは仕様があるけど、URL では query string のパース方法までは決まっていないんじゃないかなぁ。*1

; を使っても良いと解釈できずに URL Escape してしまうクローラがいる等の弊害もあり、今となっては敢えて使う必要はなさそう *2 なのでただの雑学ですが、最近見た 10 年モノの面白コードの話でした。


はてなエンジニア Advent Calendar 2020、明日 19 日目は id:Pasta-K さんです。

*1:この辺りはまだ調べ切れてないんだけど、RFC 3986 には書いてなかった

*2:なので直しました