🛠

URLエンコード関連メモ

2022/11/13に公開

ひとことで言うと

対象 ひとこと
× 空白 このエンコード方法に2種類あるのがすべての元凶
RFC3986 やばげな文字は全部 %XX で表わすルール
× RFC1738 空白が + になるし、あんまりエスケープしてくれないやつ
encodeURIComponent RFC3986 に準拠してないのでできれば使いたくない
× decodeURIComponent 間違って使うとエラーも出さずにデータを壊すので危険
× encodeURI 茨の道に進むので間違って使わないようにしたい
× escape ブラウザによって挙動が異なるので完全に忘れていい
× URLSearchParams ハッシュからクエリにできないし空白が + になるので使いたくない
URL 空白が + になるので使いたくない
strict-uri-encode RFC3986 に完全準拠
qs 有能だけど欲しいのはクエリの部分ではなくURL
query-string 本当に必要だったもの
URL Safe Base64 絶対にデータが壊れないので大事なデータはこれ
URI.js 空白が + になるので使いたくない

※個人の感想です

encodeURIComponent

encodeURIComponent(" ")                            // => '%20'
encodeURIComponent("+")                            // => '%2B'
encodeURIComponent("/")                            // => '%2F'
encodeURIComponent("*")                            // => '*'
encodeURIComponent("https://httpbin.org/get?x=#")  // => 'https%3A%2F%2Fhttpbin.org%2Fget%3Fx%3D%23'
  • に対して行う
  • URLを構成する文字もエンコードする
  • RFC3986 に近いが守っていない
    • !'()* をエンコードしていない
    • つまり RFC2396 らしい
  • Component が何を指しているのかわかりづらい
  • こんなものをグローバルに定義するとか設計がどうかしている(小声)
  • decodeURIComponent で元に戻せる
  • URL (or URLSearchParams) でも元に戻せる

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent

encodeURI

encodeURI("https://httpbin.org/get?x=あ")  // => 'https://httpbin.org/get?x=%E3%81%82'
encodeURI("https://httpbin.org/get?x=#")  // => 'https://httpbin.org/get?x=#'
  • URLに対して行う
    • とはいえ URL (URI) でないものを渡してもエラーにはならないという不親切設計
  • URLを維持するのが特徴だがそのせいで不可解なことが起きる
    • 値にURLを構成する文字が使われているとまずい
    • 例えば x の値は # であってほしいのに # をエンコードしない
    • そうなると x の値を空としてリクエストしてしまう
  • 手順前後
    • URLを作ってから値をエンコードするのではなくURLを作る前に値をエンコードするべき
  • 使うな危険
    • 値にURLを構成する文字を含んでいないか気にかけないといけないぐらいなら使わない方がいい
  • 忘れていい

escape

Google Chrome 102
escape("あ")         // => '%u3042'
unescape('%u3042')  // => 'あ'
  • ブラウザやバージョンによって挙動が異なるらしい
  • 非推奨となっている
  • 完全に忘れていい

URLSearchParams

const params = new URLSearchParams()
params.set("x", " !'()*")
params.toString()  // => 'x=+%21%27%28%29*'
  • 2016年から使えるようになった
  • が、クエリ部分をハッシュで渡せないので使いにくい
  • また欲しいのはクエリの部分ではなくURLなことが多い
  • パーサーとして使う場合もURLを引数とすることが多い
  • なのでURLクラス経由で使った方がいい
  • RFC1738 では () をエンコードしないので RFC1738 ではない
    • じゃ何?
  • decodeURIComponent でデコードするとたいへんな目に合う
    • しれっとデータが壊れる

https://developer.mozilla.org/ja/docs/Web/API/URLSearchParams

URL

const url = new URL("https://httpbin.org/get")
url.searchParams.set("x", " !'()*")
url.toString()  // => 'https://httpbin.org/get?x=+%21%27%28%29*'
  • 2016年から使えるようになった
  • URLSearchParams はこっち経由で使う方が便利
  • searchParams で URLSearchParams のインスタンスを返す
    • set や get は searchParams に委譲しといてほしかった
  • window.location はプロパティが似ているが URL のインスタンスではない (罠)
    • location.searchParams は undefined
    • new URL(location) で URL 型になる

https://developer.mozilla.org/ja/docs/Web/API/URL

URL (or URLSearchParams) は encodeURIComponent の代替にはならない

encodeURIComponent(" ")  // => '%20'
const url = new URL("https://httpbin.org/get")
url.searchParams.set("x", " ")
url.toString()  // => 'https://httpbin.org/get?x=+'
  • 近年は URL クラスを積極的に使うべきとされている
    • encodeURIComponent を使いつつ自力でURLを組み立てるのは古いやり方
  • しかし、エンコードの方法が異なるため単に置き換えると動かなくなる場合がある
  • encodeURIComponent では空白が %20 だったものが + になる
  • その影響で + を空白に復元できず動かなくなる連携アプリがあった
  • 自分で直せる部分ならよいが他者のアプリなのでこちらが合わせるしかない
  • 積極的に使うべきとされているからといって安易に移行すると死ぬ

encodeURIComponent で作ったものを URLSearchParams でエンコードしてもいい

encodeURIComponent(" ")  // => "%20"

const params = new URLSearchParams("x=%20")
params.get("x")          // => ' '
  • 空白が + ではなく %20 になっていても正しく空白に戻せていることがわかる
  • これからしても、やばげな文字はすべてパーセントエンコーディングしてくれた方が安心できる

URLSearchParams で作ったものを decodeURIComponent でデコードしてはならない

const params = new URLSearchParams()
params.set("x", " +")
params.toString()             // => "x=+%2B"

// " +" が "++" になってしまった
decodeURIComponent("x=+%2B")  // => "x=++"
  • 混ぜるな危険
  • URLSearchParams は空白を + にするが decodeURIComponent は関知しないので空白に戻らない
  • その結果、空白は単に + に置換された状態になり、元から含まれていた + と混在する形になる
  • decodeURIComponent のダメ仕様について
    • encodeURIComponent は +%2B にするので、その逆である decodeURIComponent の入力に + が混ざるのはありえない
    • ありえないことが起きているにもかかわらず例外を出さない
    • + はそのままで %2B+ に復元するため " +""++" になる
    • 間違えて使った開発者はデータが壊れたことに気づけない
  • Nuxt.js の 2.14.12 以前にはこの + が空白に復元されない不具合が潜んでおり長い間苦しめられた

中途半端な encodeURIComponent より RFC3986 に準拠したものを使いたい

const strictUriEncode = require("strict-uri-encode")
strictUriEncode(" !'()*")  // => "%20%21%27%28%29%2A"

encodeURIComponent では見逃した文字も正しくエンコードされているのがわかる。

https://github.com/kevva/strict-uri-encode

RFC3986 を守りつつハッシュからクエリ文字列を作るなら qs を使いたい

const qs = require("qs")
qs.stringify({x: " !'()*"}, {format: "RFC3986"})  // => "x=%20%21%27%28%29%2A"
  • 中途半端な encodeURIComponent より RFC3986 に準拠している qs を使いたい
  • あえて書いたが format: "RFC3986" が初期値なので指定しなくてよい
  • 一方で format: "RFC1738" にすれば空白は + になる
    • それだと URLSearchParams と同じなのでそんなに嬉しくはない
  • addQueryPrefix: true を指定すると (クエリがある場合のみ) 先頭に ? を入れてくれたりする
  • 他にも便利オプションが豊富にある
  • ハッシュから一気にクエリ文字列を作りたいことが多いので URLSearchParams より使いやすい
  • が、結局作りたいのは URL なのでこれより query-string の方がいい

https://github.com/ljharb/qs

RFC3986 を守りつつハッシュからURLを作るなら query-string を使いたい

const QueryString = require("query-string")
QueryString.stringifyUrl({
  url: "https://httpbin.org/get",
  query: {x: " !'()*"},
})  // => "https://httpbin.org/get?x=%20%21%27%28%29%2A"
  • 開発者が本当に必要だったもの
  • qs でできることはだいたいできる
  • その上で、クエリではなくURL全体を一気に作れる
    • URLクラスではハッシュから一気にクエリを作れない
    • URLクラスはそもそも RFC3986 ではないので求めているものが違う
  • fragmentIdentifier: "foo" でURLの最後が #foo になる
  • 内部で有能な strict-uri-encode を使っている

https://github.com/sindresorhus/query-string

URI.js

const URI = require("urijs")
URI("https://httpbin.org/get").query({x: " !'()*"}).toString()
  // => https://httpbin.org/get?x=+%21%27%28%29%2A
  • URI.js も query-string と似たようなことができる
  • インターフェイスがわりと洗練されている
    • でも何でもハッシュで渡したいときは query-string の方が良さそう
    • 好みが分かれる
  • .fragment("foo") でURLの最後が #foo になる
  • 最大の問題は RFC3986 ではないこと
    • 空白が + になってしまう

https://github.com/medialize/URI.js

Ruby 側のエンコード方式各種

require "cgi"
require "uri"
require "erb"
require "active_support/core_ext/object/to_query"
require "rack"

# RFC3986 と思われる (おすすめ)
# Rails のビューのヘルパーメソッド u はこれ
ERB::Util.url_encode(" !'()*")           # => "%20%21%27%28%29%2A"

# RFC3986 と思われる
# ただし Ruby 3.2 以上にしかない
CGI.escapeURIComponent(" !'()*")         # => "%20%21%27%28%29%2A"

# RFC1738 でも RFC2396 でも RFC3986 でもない何か
CGI.escape(" !'()*")                     # =>   "+%21%27%28%29%2A"

# 残念ながら to_query は CGI.escape を呼んでいる
" !'()*".to_query("q")                   # => "q=+%21%27%28%29%2A"

# application/x-www-form-urlencoded エンコード方式
# RFC1738 でも RFC2396 でも RFC3986 でもない
URI.encode_www_form_component(" !'()*")  # => "+%21%27%28%29*"

# 残念ながら Rack::Utils.escape の中身は URI.encode_www_form_component
Rack::Utils.escape(" !'()*")             # => "+%21%27%28%29*"

Ruby にもひそむ不整合の罠

もう一度書くと encodeURIComponent は +%2B にするので、その逆である decodeURIComponent の入力に + が混ざるのはありえないにもかかわらず例外を出さないため、エンコード・デコードの掛け違いで +++ になる不整合が起きても気づかないという罠が JavaScript にはある。

それとまったく同じ罠が Ruby (のライブラリ) にもある。

CGI.escape(" +")                  # => "+%2B"
CGI.unescapeURIComponent("+%2B")  # => "++"

配慮が致命的に欠けているトンデモ仕様を模倣する姿勢にはがっかりである。このような仕様ではまともに使えないので次のモンキーパッチを当てて、

CGI.extend Module.new {
  def unescapeURIComponent(str)
    if str.include?("+")
      raise "Doesn't conform to RFC3986 format : #{str.inspect}"
    end
    super
  end
}

+ を含む入力には、

CGI.unescapeURIComponent("+%2B") rescue $!  # => #<RuntimeError: Doesn't conform to RFC3986 format : "+%2B">

せめて例外を出すようにしたい。

%2520 や %252B を見たら状況は深刻

ERB::Util.url_encode(" ")           # => "%20"
ERB::Util.url_encode("%20")         # => "%2520"

ERB::Util.url_encode("+")           # => "%2B"
ERB::Util.url_encode("%2B")         # => "%252B"

# 最悪のパターン
URI.encode_www_form_component(" ")  # => "+"
ERB::Util.url_encode("+")           # => "%2B"
ERB::Util.url_encode("%2B")         # => "%252B"
  • これは特別な文字を4桁の16進数で表しているのではない
  • 空白または + を二重にエスケープしてしまっている
  • つまり二重にエンコードしている
    • 最悪のパターンでは三重にエンコードしている
  • %2520%252B を見たら、かなりおかしな状況になっていると考えた方がいい

URL Safe Base64 最強

Ruby
require "base64"
Base64.urlsafe_encode64(" +", padding: false)  # => "ICs"
JavaScript
// https://github.com/dankogai/js-base64
import { Base64 } from "js-base64"
Base64.encodeURI(" +")  // => "ICs"
  • 安全そうな文字だけを使うため二重にエスケープしてしまう心配から解放される
  • 簡易的な暗号化にも使える
  • 復元されても鍵がないと読めないようにするなど工夫すれば機密データも送れる
  • あたりまえだけどブラウザやフレームワークは URL のクエリが Base64 だと判断できないため自動的に復元してくれない

各種エンコードの違い

str = 128.times.collect(&:chr).join.gsub(/\P{Print}|\p{Alnum}/, "")
str  # => " !\"\#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"

として作ったやばげな文字列を与えた結果を見てみる。

種類 結果
RFC3986 "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
CGI.escapeURIComponent "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
ERB::Util.url_encode "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
strict-uri-encode "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
qs "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
query-string "%20%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
URI.encode_www_form_component "+%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E"
URLSearchParams "+%21%22%23%24%25%26%27%28%29*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D%7E"
CGI.escape "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
URI.js "+%21%22%23%24%25%26%27%28%29%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
RFC1738 "+%21%22%23%24%25%26%27()%2A%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
encodeURIComponent "%20!%22%23%24%25%26'()*%2B%2C-.%2F%3A%3B%3C%3D%3E%3F%40%5B%5C%5D%5E_%60%7B%7C%7D~"
URL Safe Base64 "ICEiIyQlJicoKSorLC0uLzo7PD0-P0BbXF1eX2B7fH1-"

Discussion