URL エンコードが壊れるのはなぜ? パーセントエンコードと + vs %20
café を URL に入れると café で出てくる。空白はある場所では +、別の場所では %20。値の中の & がクエリを真っ二つに割り、後半が消える。何かが二度エンコードされて %2520 を眺めている。どれも別々のバグに見えて、実は一つの考えに二つのひねりだ——パーセントエンコードは URL がそのまま書けないバイトを逃がし、規則は部位で変わり、空白には合法な書き方が二つある。
café を URL に入れると、受け手側では café として現れる。空白をエンコードすると、ある場所では +、別の場所では %20 になり、どちらが正しいのか分からない。& を含む値がクエリ文字列を真っ二つに割り、後半が静かに消える。何かが二度エンコードされ、%2520 を眺めている。どれもそれぞれ別のバグに見えるので、それぞれに別のごまかしを当てる——ここでデコード、あそこで .replace()、もう一度エンコード——すると一つ下の層で別のものが壊れる。
これら全部の底にある事実はこうだ。パーセントエンコードとは、URL がそのまま書くことを許されないバイトを、URL に運ばせるための方法だ。URL のシリアライズされた形は限られた ASCII 文字しか使えず、その外にあるもの——非 ASCII のテキストや、& や / のように構造的な役目を持つ文字が値の内側に現れた場合——はすべて、% に各バイトの二桁の十六進を続けた形へ逃がさねばならない。仕組みはこれだけだ。混乱は、その上に重なった二つのひねりから来る。「どの文字をエンコードすべきか」という規則は、URL のどの部位にいるかで変わる。そして空白には二つの合法なエンコード——%20 と +——があり、それらはちょうど一つの文脈でだけ同じものを意味し、それ以外の場所では別々のものを意味する。
この記事は、パーセントエンコードとは何かを簡単に説明し、それからうまくいかない数通り——+/%20 の分かれ、一部分だけのつもりで URL 全体をエンコードしてしまう、café を %C3%A9(扱いを誤ると café)にする UTF-8 の一段、そして %20 を %2520 にする二重エンコード——を歩く。読み終えるころには、崩れて出てくるどんな URL にも当てられるチェックリストが手に入る。
唯一の核心:バイトを % と二桁の十六進に逃がす
URL のシリアライズされた形は限られた ASCII 文字しか含められない——だから非 ASCII のテキストは、まず(UTF-8 で)バイトに変換し、エスケープしてからでないと運べない。パーセントエンコード——別名 URL エンコード——はその脱出口だ。ここで許されないバイトを取り、% にそのバイト値の二桁の十六進を続けて書く。空白はバイト 0x20 なので %20 に、/ はバイト 0x2F なのでパス区切りではなくデータであるときは %2F になる。二つの文字グループが規則を決める。
- 非予約文字——
A–Z a–z 0–9 - . _ ~——常に安全で、エンコードは要らない。 - 予約文字——
: / ? # [ ] @ ! $ & ' ( ) * + , ; =——これらは構造的な意味を持つ。クエリとパスを分け、あるパラメータと次を分け、キーと値を分ける。区切りとしては合法だが、そのどれかが値の内側に現れたら、エンコードしなければならない——さもないと構造として読まれる。
その二点目が、「& がクエリを割った」バグを一文で言い切る。値の内側でエンコードされていない & は、二つのパラメータの間の & と区別がつかず、その後ろのすべてが新しいパラメータとして解釈される。
+ と %20:空白の二つの顔
URL エンコードで最も人を惑わせるのは、どちらの書き方も正しく、ただ使う場面が違うという点だ。空白には二つのエンコードがあり、どちらが正しいかは部位で決まる。
- パスと URL の大半では、空白は
%20。そこの字面の+はただのプラス記号だ。 - フォームエンコードされたデータ(
application/x-www-form-urlencoded)——多くはクエリ文字列——では、空白は+で、字面のプラスは%2Bと書かねばならない。
だから一般の URL コンポーネントでは %20 が空白の曖昧さのない書き方であり、「+ は空白」は application/x-www-form-urlencoded の慣習——フォームエンコーダが従う規則で、最もよく見えるのはクエリ文字列だが、フォームのリクエストボディにも現れる。バグはその境界に棲む。パスで + を空白として扱うデコーダは本物のプラスを壊し、フォームエンコードされたデータで + を空白として扱わないデコーダは、空白であるべき場所に字面の + を残す。同じ入力で、二つの慣習が分かれるのが見える。
encodeURIComponent("a b") // "a%20b"
new URLSearchParams({ q: "a b" }).toString() // "q=a+b"
encodeURIComponent は一般の URL コンポーネント向けなので %20 を出し、URLSearchParams はフォームとしてシリアライズするので + を出す。これがまさに Base64 へ手を伸ばす罠でもある——フォームエンコードされたデータに乗せて運ばれた Base64 は、その + がフォームデコードで静かに空白に変わることがあり、「Base64 がデコードできない」バグの正体が、実はこの +/空白の規則ということがある。迷ったら、空白は %20、プラスは %2B にエンコードすれば、この曖昧さは消える。
一部分をエンコードせよ、URL 全体ではなく
「エンコードが壊れた」の大きな割合は、間違った粒度でのエンコードだ。仕事は二つ、道具も二つある。
- URL 全体のエンコードは、構造的な文字(
: / ? # & =)をそのまま残す。それらは役目を果たしているからだ。JavaScript ではencodeURI。 - 一つのコンポーネントのエンコード——単一のクエリ値、一つのパスセグメント——は、
/ ? # & =を含むすべての予約文字を逃がさねばならない。ここではそれらはデータであって構造ではないからだ。これがencodeURIComponent。
URL 全体のエンコーダを値にかければ、その & と = はエンコードされずにすり抜け、クエリを吹き飛ばす。コンポーネントのエンコーダを URL 全体にかければ、その :// と ? がエンコードされ、もう経路をたどれない、正しく解釈されない文字列になる。規則はこうだ。エンコード済みのコンポーネントから URL を組み立てよ。完成した URL を一つの文字列としてエンコードしてはならない。各値をコンポーネントのエンコーダでエンコードし、それから構造として残したい字面の ?・&・= でつなぐ。
非 ASCII:まず UTF-8、それからパーセントエンコード
café は %café にはならない。パーセントエンコードはバイトに作用し、é のような文字は一バイトではない——だから隠れた一段がある。テキストはまず UTF-8 でバイトにエンコードされ、それから各バイトがパーセントエンコードされる。é は UTF-8 の二バイト 0xC3 0xA9 なので %C3%A9 になり、café は caf%C3%A9 になる。CJK の一文字はたいてい三バイトなので、%XX が三組だ。これがまさに café が café になる理由である。バイトは UTF-8 でエンコードされたのに、下流のどこかが Latin-1 としてデコードし、Latin-1 では 0xC3 0xA9 が é と読めるのだ。URL の文字化けはほぼ常に、UTF-8 と別の何かの食い違いであって、パーセントエンコードの失敗ではない。そしてその %XX の対はただの十六進だ——%C3 はバイト値 0xC3——これは十六進を流暢に読めると、エンコードされた URL が急に読めるようになる理由でもある。
もう一つ分けておく価値がある。URL のホスト部分は、非 ASCII にパーセントエンコードを使わない。café.com は Punycode / IDN を通して xn--caf-dma.com になる。まったく別の仕組みだ——だから非 ASCII のドメインと非 ASCII のパスは、二つの異なるシステムで逃がされており、それを混同するのはそれ自体が混乱の源だ。
二重エンコード:%20 はどうやって %2520 になるか
典型的な後段の問題だ。パーセントエンコードは冪等ではない。すでにエンコードされた文字列をエンコードすると、% 記号自体がエンコードされる。% はバイト 0x25 → %25 だからだ。だから %20(エンコードされた空白)をもう一度エンコーダに通すと %2520 になり、読む側は空白ではなく字面の %20 をテキストに見る。手がかりは、置いた覚えのない場所に現れる %25 だ——%2520、%253A、%2526。これは、値があなたのコードでエンコードされ、そのあとフレームワーク、HTTP クライアント、あるいは値をまだ生だと思い込んだリダイレクトによってもう一度エンコードされたときに起きる。直し方はちょうど一度だけエンコードすること。二重に包んでいる層を突き止め、そのうちの一つだけに仕事をさせる。層を見るには、文字列を URL エンコード/デコードツールに貼って繰り返しデコードする。一回ごとに一層はがれ、%25 が % に戻ったとき、何回包まれていたかが分かる。ただし繰り返しのデコードはここでは診断であって、修正ではない。本番で無闇にループしてデコードしてはいけない。正しくエンコードされた一つの値が、それ自体に字面の %25 や %20 を正当に含んでいることがあるからだ。
それでも噛みつく細部
仕様に潜む細かな注意点がいくつか。
encodeURIComponentは! ' ( ) *をエンコードしない。RFC 3986 に厳格なサーバーはこれらも逃がしてほしがるので、うるさいエンドポイントが拒むなら手でエンコードする。- パスの
+は字面のプラス。+を空白と読むのはapplication/x-www-form-urlencodedのデータだけなので、+を空白に変えてパスを「直す」ことはしない。 #は静かに切り捨てる。値の中のエンコードされていない#は URL のフラグメントを始め、その後ろのすべてはサーバーに届かない——データの#は%23でなければならない。
URL エンコードが崩れたときのチェックリスト
URL が正しく出てこないとき、手当たり次第に文字を置き換えない。この順で問う。
- どのコンポーネントか。URL 全体は
: / ? # & =を構造として残し、単一の値はそれらを逃がす。値にはコンポーネントのエンコーダ(encodeURIComponent)、URL 全体には URL のエンコーダ(encodeURI)。 - 空白が
+か%20か。application/x-www-form-urlencodedのデータでは+が空白を表し、それ以外の URL コンポーネントでは%20。安全のため、空白は%20、プラスは%2B。 - アクセントや CJK が化けた。それは UTF-8 と文字集合の食い違いで、パーセントエンコードではない——両端を UTF-8 でそろえる。ホスト名は
%XXではなく Punycode を使う。 - 置いた覚えのない
%25が見える。二重エンコードだ——%25が%に戻るまでデコードし、それからちょうど一度だけエンコードする。 - 値が途切れる、クエリが割れる。値の中のエンコードされていない
#・&・=が構造として読まれている——エンコードする。
この五つの底にあるのは、あの唯一の核心だ。パーセントエンコードはバイトを % と二桁の十六進に逃がし、難しいのはデータと構造を見分けることと、空白が + になる唯一の文脈だけ。自分がどちらの側で間違えているのかを名指しすれば、崩れて見えた URL は、きれいに解ける。