Python
JavaScript
Go
URI

encodeURIComponentが世界基準だと誤解してた話

URLをいじくるプログラムをいじっていて、仕様がよくわからなくて悩んだのでまとめます。

2/23: 追試部分を追記

2018/7/14: JavaScriptのURLSearchParamsと、GoのPathEscapeについて追記


ことの経緯

HTTPとはなんぞやとか、GETとPOSTがどうの、それぞれでパラメータがどういう経緯でウェブアプリケーション(とかCGI)に渡って来るのかぐらいは知っていました。で、ウェブでXHRでGETリクエストを送る場合にはJavaScriptのencodeURIComponent()で各パラメータをエンコードして、&でくっつけて、URLの末尾に?で付与すればいいんだよね?と思っていました。こんな感じに。

var finalUrl = [url, "?", encodeURIComponent("key"), "=", encodeURIComponent("value")].join("");

これを受け取る側は、JavaScript(node.js)ならdecodeURIComponent()を使っているはずだ。ウェブの世界ではJavaScriptが中心だし、きっと他の言語のWeb系のAPIはJavaScript基準に合わせているのでは?と。

そう思っていたところ、Golangのnet/urlurl.QueryEscape()関数がencodeURIComponent()とはちょっと違う結果を返すことに気づきました。僕の感覚ではスペースは%20になると思ってたんですが、Golangでは+になります。Golangが生成したエンコード文字列を、JavaScriptのdecodeURIComponent()で戻すと、+が残ってしまいます。さて、困った。どこが原因なのか?


現状把握


RFC

URIの形式については、RFC1738 (1994年)RFC2396 (1998年)RFC3986 (2005年)とバージョンがあがってきているようです。最新版だとAdobeの人が関わっているんですね。へぇーへぇー。時間があればあとで読む。

Stack Overflowの投稿によると、GolangはRFCに厳格らしく、JavaScriptはちょっとルーズらしいです。


JavaScriptのエスケープ

JavaScriptだと、encodeURI()と、encodeURIComponent()があります。encodeURI()は&と=、/はそのままにするバージョンで、完全なURIを受け取って、そのままURIとして使える文字列を返す想定で、encodeURIComponent()はクエリパラメータで使える安全な文字列(URIの意味を壊さない)を返します。このエントリーでは後者だけを相手にします。

Content-Typeがapplication/x-www-form-urlencoded(POST)で送信するときは、さらに%20を+に変換せよ、とMDNの説明にはあります。また、RFC-3986に厳格に対応するためのコードも書かれてます。

function fixedEncodeURIComponent (str) {

return encodeURIComponent(str).replace(/[!'()*]/g, function(c) {
return '%' + c.charCodeAt(0).toString(16);
});
}

つまり、JavaScriptのエスケープの仕様はRFCの最新には対応していないということですね。ブラウザで検索するとスペースは常に%20になるかと思っていましたが、ウェブサイトで検索すると今は+になる模様。前からそうでしたっけ?ただ、場所によっては色々あるっぽいです。

ECMAScriptの標準JavaScriptの範囲内だと、encodeURIComponent()しかありませんが、node.jsには、このRFCに厳格なバージョンにも対応できる、querystringモジュールがあります。expressは、qsというサードパーティ製のモジュールを利用しているようです。

2018/7/14

ウェブブラウザではURLSearchParmasというクラスが実装されました。Node.jsも8.0から実装されています。これだときちんと+になります。配列の処理とかパースとかも全部このクラスがまとめて面倒を見てくれますので、今後はみなさんこれを積極的につかいましょう。

>>> const params = new URLSearchParams()

>>> params.append(" ", " ")
>>> params.toString();
"+=+"


golangのエスケープ

golangでは net/url モジュールが提供されています。 url.Values.Encode() を呼ぶと検索クエリをエスケープした文字列を作ってくれます。ソース読むと中で各コンポーネントの変換に使っているのは、 url.QueryEscape です。この関数は中でプライベート関数のescapeを使っていて、この関数はモードによって変換の仕方が少し変わる模様。パス部分のエンコードでは、スペースは%20になりますが、クエリ部分のモード(url.QueryEscape()と同様)では、+になります。mattnさんの書かれた方法を使えば%20にできないこともないです。

2018/7/14

Go 1.8からは url.PathEscapeが提供されるようになったため、プライベート関数をがんばって駆使しなくてもパス部分にマッチしたエスケープができるようになりました。mattnさん、コメントありがとうございました。

https://twitter.com/mattn_jp/status/788275361351999488


Pythonのエスケープ

Python3のurllib.parseには、quote()と、quote_plus()があります。quoteはJS相当、quote_plusはGolang相当っぽいです。さすが俺たちのPython。卒なくこなしやがる。


結局どうすべきか?

現状認識としては、Golangの挙動は正しいものの、JavaScriptのように古い規格のクエリを投げてくるクライアントもありえる、というのが実際のところかな、と思いました。もちろん、JavaScriptでも厳密に守ったコードを投げることもできますが、必ずしもそうはなっていない(Chromeのオムニバーでも)。

そのため、サーバ側で、スペースがどっちできてもきちんと解釈できるようにしておかないとダメってことですね。まぁ、このあたりはウェブアプリケーションサーバのフレームワーク側がやってくれる話なので、自分でソケット、もしくは 低レベルなHTTP APIを利用してウェブアプリを作る人以外は意識する必要はないのかもしれませんが・・・

クライアントは、たとえそのリクエストがapplication/x-www-form-urlencodedだろうがそうじゃなかろうが、RFC3986の厳しいエンコードのしておけば良いということですかね。とりあえず、今書いているコードは直さなくても良さそうなことが分かりました。

Twitter上で色々コメントくださった方々、どうもありがとうざいました。


追試: ウェブのフォームはRFC関係ない?

識者からコメントつきました。外に情報出すと、知識が跳ね返ってくる感じがいいですね。

なるほど。RFCは関係ないと。確かに、HTML5のw3cの仕様の中にフォームの送信ということで、具体的に書かれています。このリンク先の4.5.以下が各パートの変換方法を示しています。


  1. 半角スペース(0x20)なら+(0x2B)に直す。


  2. A-Z, a-z, 0-9, *, -, ., _はそのまま通す。

  3. 以外の文字は %コード に直す。

と。なるほどなるほど。Goはnet/httpのサンプルでurl.Valuesを使っていたので、それを使ってみます。

gore> :import net/url

gore> url.Values{"key": {"!\"#$%&'()*+,-./:;<=>?@\\[]^_"}}.Encode()
"key=%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_"

ぬ・・・残さなければならないはずの*が変換されてしまっている・・・Golangは内部で、url.QueryEscape()を呼んでいるので、RFC3986相当ですね。

じゃあPythonはどうですかね?

>>> import urllib.parse

>>> urllib.parse.quote_plus("!\"#$%&'()*+,-./:;<=>?@\\[]^_")
'%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_'

Golangと同じ結果になりました。

コメントでriocamposさんに教えてもらったRubyのWEBrickのライブラリでテスト。

irb> require 'webrick/httputils'

irb> puts WEBrick::HTTPUtils.escape_form("!\"#$%&'()*+,-./:;<=>?@\\[]^_")
!%22%23%24%25%26'()*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5C%5B%5D%5E_

あれ・・・・本来は変換しなきゃいけない!'()、が残ってる・・・

仕様通りに実装された処理系がないという衝撃の結果に(MDNでこうしなさい、という参考実装が提供されているJavaScript以外)。%xxとなっているのを戻すというのは汎化されたロジックで実装されているだろうから、GolangとかPython方面の方法が安全ですかね。まぁ、制御文字ではないので、Ruby方式で送って問題になるということもなさそうですが。

あと、確かにコメントでいただいたように、今はRFCは関係ないですが、昔はHTMLはRFC1866ということで、この中にフォームの変換について記述されています。この時に、すでにスペースは+にすると書かれていますね。ただし、変換方法の詳細については書かれてません。その後はRFC2854で更新されますが、この中には仕様は書かれてなくて、W3C側が今後は面倒を見ていくよ、となっています。なるほど。


追記後のまとめ


  • URIのQueryはRFC3986。GolangとPythonがそれを満たしている。JavaScript, Rubyはちょびっと違う結果を返す?

  • POST時のエンコード(application/x-www-form-urlencoded)に関してはRFCではなく、HTML5の仕様に書かれている。RFC3986の変換とは微妙に異なる。で、それを満たしたエンコーダは現時点では見当たらず。JSに関してはMDNに参考実装あり。