URLエンコードについておさらいしてみた

# はじめに

こちらはOptTechnologiesの社内勉強会で利用した資料になります

---

# 調べた動機

ユーザからの問い合わせで、計測時に上がってくるURLが登録しているものと違うと言われたところから調査していたら、二重エンコードが問題になっていたことがありました
この時に、あ、俺URLエンコードよくわかってないな、と思ったのでいい機会だから調べてみようと思った所存です

---

## URLエンコードとは

### なんかよくわかんないけどマルチバイト文字っぽいのを`%xx`の形に直してくれるやつ！

---

冗談はさておき。

---

## URLエンコードとは

一般に世間で言われているURLエンコードには2種類のものがあります

1. RFC3986にて規定されているUniform Resource Identifier(URI)の仕様の一部として定められているもの
2. HTMLのFormで送信するデータ種類の1種である、`application/x-www-form-urlencoded`の仕様として定められているもの


つまり、単に`URLエンコード`と称したときに、その意味は一意に定まらないようです・・・

---

## RFC3986のURLエンコードについて

- 参考資料: https://triple-underscore.github.io/RFC3986-ja.html#section-2
- URIの定義として、利用可能な文字を定めている項があり、そこでエンコードも含めた文字表現として規定されている
- ざっくり以下のような定義になる
    - 利用可能な文字は予約文字、非予約文字の2種類あり、これらは一定のルールに基づいてエンコードされたりされなかったりする（詳細は後述)
    - それ以外の文字はすべてパーセントエンコードを実施する

---

### パーセントエンコードについて

`pct-encoded` = `"%" HEXDIG HEXDIG`

- `%XX`(Xは16進数の値)の3桁の値として表現
- 例えば、"%20"はスペースに該当する、など
- パーセントエンコードは文字列のバイト配列とのマッピングなので、文字コードにかかわらずエンコードが可能
    - たとえば「ウィキペディア」を、各種の文字コードを用いてパーセントエンコーディングで符号化すると以下のようになる。
        - Shift_JIS - %83E%83B%83L%83y%83f%83B%83A
        - EUC-JP - %A5%A6%A5%A3%A5%AD%A5%DA%A5%C7%A5%A3%A5%A2
        - UTF-8 - %E3%82%A6%E3%82%A3%E3%82%AD%E3%83%9A%E3%83%87%E3%82%A3%E3%82%A2
        - (wikipediaより引用)

---

### 利用可能な文字について

#### 予約文字

`reserved`　= `gen-delims` / `sub-delims`

`gen-delims` = ":" / "/" / "?" / "#" / "[" / "]" / "@"

`sub-delims` = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "="

- これらはURIで区切り文字などに利用される
- `gen-delims`はURI構成要素の区切り文字として機能する
    - 例:`http://example.com`の場合、プロトコル部とドメイン部を`://`によって区切っている
    - その特性から、URI構成要素内でこれらの値を利用したい場合、条件に基づいてパーセントエンコードが必要
        - 例えばクエリストリングのvalueで`?`を含む値の場合など
        - URI構成要素によって区切り文字として機能するかどうかが異なるので、`gen-delims`だからと言ってまるっとエンコードしていいわけではない
- `sub-delims`はURI構成要素の区切り文字以外で利用される区切り文字
    - 例えばクエリストリングのkeyとvalueを区切るために`=`が利用されている

---

#### 非予約文字

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

- 自由に使って良い文字列とされている
- あるURIにおいて、これらの値をパーセントエンコードしたものとしていないものは**等価として扱う**という仕様が定められている
- 仕様上そう定められているが、アプリケーションがその同一性を担保してくれるかは実装によるため、URIを生成するアプリケーションはこれらの非予約文字を**パーセントエンコードするべきではない**
- 逆に、URIを解釈するアプリケーションはこれらの文字を必ずデコードするべきであるとも規定されている

---

### エンコード/デコードについて

- 通常、エンコード処理はURI生成時にのみ実施される。生成されたURIは常にパーセントエンコードがされた状態であると規定されている
    - `hoge=ふが`、`foo=ばー`がクエリストリングとして付いたURIを生成するとして、`http://example.com/?hoge=%E3%81%B5%E3%81%8C&foo=ばー`のような状態ではURIとして認められない
- URIから元の値を取り出したいという場合、デコード処理の前にURI構成要素を適切に分割する必要がある
    - `foo=A&&B`,`bar=C||D`がクエリストリングとして付いた`http://example.com?foo%3DA%26%26B&bar%3DC%7C%7CD`をパースするときに、先にデコードしたら`http://example.com?foo=A&&B&bar=C||D`になってしまうため
- エンコード/デコードは複数回してはいけない
    - 元の文字列に`%`が含まれていた場合、これをパーセントエンコードの始まりと解釈しうるため
    - `金利2%20年ローン` => `金利2 年ローン`になってしまう
    - 自分が携わっている広告計測のプロダクトで、2重エンコードされたURLが来てしまう場合に2重デコードしてしまう案もあったが、上記のような問題が想定されたので実施はされなかったという経緯があったりなかったりしました

---

### 以前のURIの仕様について

- RFC3986の前身にあたる仕様としてRFC2396というものがあり、こちらでも同様に`reserved`が定義されている
- `reserved`の内容が**異なる**
- encode系の実装が**この仕様に基づいていることがある**
    - JavaScriptの`encodeURIComponent`など
    - 参考: https://qiita.com/shibukawa/items/c0730092371c0e243f62 , http://freak-da.hatenablog.com/entry/20080321/p1

---

## application/x-www-form-urlencodedのURLエンコードについて

- 参考資料（WHATWGとW3Cどっちの仕様が正かあんまり把握してないのでとりあえず両方抜粋
    - WHATWG https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#url-encoded-form-data
    - W3C https://www.w3.org/TR/html401/interact/forms.html#h-17.13.4.1
- Form要素のsubmit時に送られるデータ形式の仕様として、parse/serializeの方法が定義されており、これがそれぞれデコード/エンコードの仕様にあたる

---

### WHATWGにて定義されているエンコードのルール

資料: https://url.spec.whatwg.org/#urlencoded-serializing

`byte serializer`として、以下のような処理を定義(概ねパーセントエンコード関数みたいなイメージ）

byte配列を入力とする

- 入力の全てのbyte配列に対し、以下の処理を実施
    - `0x20 (SP)`なら`U+002B (+)`を出力として追加
    - `0x2A (*)`, `0x2D (-)`, `0x2E (.)`, `0x30 (0) to 0x39 (9)`, `0x41 (A) to 0x5A (Z)`, `0x5F (_)`, `0x61 (a) to 0x7A (z)`なら、そのまま出力として追加
    - それ以外ならパーセントエンコードしたものを出力として追加
- 処理が完了したら、それを結果として出力する

---

### WHATWGにて定義されているエンコードのルール

`application/x-www-form-urlencoded serializer`を定義する（これがURLエンコードとして機能することになる）

`[name-value]か[name-value-type]を要素とするリスト`及び、`文字エンコーディング上書き設定`を入力とする

- デフォルト文字エンコーディングをUTF-8とする
- もし`文字エンコーディング上書き設定`があれば、そのエンコードを採用する
- 以下の処理を`[name-value]か[name-value-type]を要素とするリスト`の全ての要素に対して実施する
    - byte serializerを`name`要素に対して適用したものをnameとする
    - もし`value`が、`type`を持つ場合、
        - もし`type`が`hidden`かつ、`name`が`_charset_ `の場合、`value`を文字エンコーディングで上書きする
        - もし`type`が`file`なら`value`を`value`のファイル名で上書きする
    - byte serializerを`value`要素に対して適用したものを`value`とする
    - 今処理している要素が最初の要素ではないなら、出力に`U+0026 (&)`を追加する
    - 出力に`name` + `U+003D (=)` + `value`を追加する
- 処理が完了したら、それを結果として出力する

---

### application/x-www-form-urlencodedについて

- 空白が`+`になるのはこいつのエンコードの仕様。 ~~ファッキン~~
- 歴史的経緯によりこのような複雑な仕組みになっているとのこと
- `<input type="hidden" name="_charset_">`とか初めて知った・・・

---

## 所感とか

- SPAの文脈ではRFC3986のことだけ知っておけば概ね生きていけそう
    - でもURLエンコードという単語が一意に定まらないことは知っておかないとハマりそう
- RFC3986の仕様の方はURIを扱う時に気を付けるべきことについても知ることが出来るので、Web開発してる人は読んでおいて損はないと思った
- URLのencode/decodeの処理は実装によってどの仕様に基づいているかが異なるので、そこだけは気を付けるべし
- ブラウザの世界観が複雑で辛い案件をまた一つ知ってしまった