URLエンコード・デコード
URLエンコード・デコードは、テキストのパーセントエンコードとデコードをブラウザ内で行います。モードの選択は見た目以上に重要です。クエリ文字列ではスペースは+ですが、パスでは%20であり、間違った方でフォームボディをデコードすると、エラーは一切出さずに「a + b」が「a b」に変わります。
URLエンコード・デコードはどう動きますか?
「URLエンコード」という名前の下には、3つの異なるエンコードがあります。間違ったものを選ぶと、決してエラーを投げないバグが生まれます。
パーセントエンコードとフォームエンコードは別物
RFC 3986のパーセントエンコードでは、スペースは%20と書きます。application/x-www-form-urlencoded形式(クエリ文字列とフォームのPOSTボディが実際に使う形式)では、スペースは+と書かれます。つまり、リテラルのプラスは%2Bと書く必要があります。フォームボディをdecodeURIComponentでデコードすると、+はそのまま残ります。その結果、検索語「a + b」は、ユーザーがプラスを入力したときには「a + b」として届き、スペースを入力したときには「a+b」として届きます。どちらか一方は間違いなのに、どちらもエラーを出しません。
encodeURIComponentはRFCが予約する5文字をそのままにする
!、'、(、)、*の5文字はRFC 3986が予約しており、encodeURIComponentはこれらをそのままにします。たいていは無害です。しかし、エンコード結果を入力に署名を計算する場面では無害ではありません。だからこそ、AWS SigV4とOAuth 1.0aのライブラリはどれも独自のエンコーダを同梱しています。strictオプションはこれらもエンコードします。
URLの各構成要素にはそれぞれ固有のルールがある
パスには/を含められます。一方、クエリの値にエンコードしていない&を含めると、そこで2つのパラメータに分断されます。これがコンポーネントモードとURL全体モードの違いです。前者はunreserved以外のすべてをエンコードし、後者はURLの構造を形作る文字を保護します。URL全体をencodeURIComponentでエンコードすると、もはやURLではない文字列ができます。
二重エンコードと、その見分け方
%2520は、エンコーダを2回通ったスペースです。%20の%が、今度は%25としてエンコードされたものです。ログを流し読みする人には「%20」と見え、デコードした値にはリテラルの%20が生じます。このツールは、入力が何回のデコードに耐えるかを数えるので、紛れ込んだ余分な層は、不可思議な挙動ではなく目に見えるものになります。
URLの分解にはプラットフォームのパーサを使う
構成要素の分解には、正規表現ではなくブラウザ自身のURL実装を使っています。URLのパースにはuserinfo、IPv6リテラル、デフォルトポート、パーセントエンコードされたホストといったエッジケースが山ほどあり、プラットフォームに既にあるWHATWGアルゴリズムの方が、どんなパターンよりも短く、より正確だからです。重複するクエリキーは順序を保ったまま残します。多くのAPIがこれに意味を与えているからです。
入力
q=a + b
エンコード結果
component: q%3Da%20%2B%20b form: q%3Da+%2B+b
URLエンコード・デコードが対応するオプションとエッジケースは?
| パラメータ | 型 | デフォルト | 動作とエッジケース |
|---|---|---|---|
| コンポーネント | encodeURIComponent | デフォルト | クエリ値1つやパスセグメント1つ向けです。A–Z a–z 0–9 - _ . ~と下の5文字以外はすべてエンコードします。 |
| URL全体 | encodeURI | — | URL全体向けです。: / ? # [ ] @ ! $ & ' ( ) * + , ; =を残すので、URLがURLのまま保たれます。ここでコンポーネントモードを使うと構造が壊れます。 |
| フォーム | x-www-form-urlencoded | — | クエリ文字列やPOSTボディ向けです。スペースは+になり、リテラルの+は%2Bになります。正しくデコードするには、パーセントデコードの前に+を戻す必要があります。さもないと、リテラルの+がスペースに化けます。 |
| base64url | RFC 4648 §5 | — | URL安全なbase64のアルファベットです。+と/の代わりに-と_を使い、パディングは取り除きます。JWTのセグメントがこれを使っています。 |
| RFC 3986厳格 | 真偽値 | オフ | encodeURIComponentがそのままにする! ' ( ) *もエンコードします。エンコード結果を署名に使う場合に必要です。 |
| すべてエンコード | 真偽値 | オフ | 予約されないバイトも含め、すべてのバイトをパーセントエンコードします。見栄えは悪いものの有効で、うるさいゲートウェイが受け付ける唯一の形になることもあります。 |
| 文字セット | UTF-8 | 常に | ASCII以外はUTF-8のバイト列として、1バイトごとに%XXでエンコードされます。現在のすべてのブラウザとサーバーがこれを想定しています。 |
| 不正な入力 | 報告 | 位置付き | decodeURIComponentは素のURIErrorを投げるだけです。こちらでは問題のシーケンスを特定し、名前付きで報告します。 |
よくある質問
スペースが%20ではなく+になるのはなぜですか?
フォームモードになっているからです。これはクエリ文字列やフォームのPOSTボディでは正しいモードです。この形式は、スペースを+と定義しています。パスセグメントやフラグメントでは、スペースは%20になり、+はリテラルのプラスです。どちらのエンコードも正しいもので、位置に合わない方を使うと値が壊れます。
encodeURIとencodeURIComponent、どちらを使うべきですか?
1つの部品、つまりクエリの値、パスセグメント、フラグメントにはencodeURIComponentを使います。URLとしての機能を保たせたいURL全体にはencodeURIです。判定方法は単純です。エンコードするテキストに構造を担う文字(?、&、/など)が含まれていて、それを機能させたままにしたいならencodeURIが必要で、データに変えてしまいたいならencodeURIComponentが必要です。
%2520とは何ですか?どうやって発生するのですか?
スペースが2回エンコードされたものです。1回目で%20になり、2回目でその%が%25としてエンコードされ、%2520になります。たいていは、アプリケーションコードがエンコードした値を、フレームワークやプロキシがさらにエンコードしたことを意味します。上の深度カウンタは、入力が何回のデコードに耐えるかを示すので、層が1つ多すぎないかを判断できます。
!'()*がエンコードされないのはなぜですか?
RFC 3986が予約しているにもかかわらず、encodeURIComponentは意図的にそのままにします。ほとんどの場合は無害です。ただし、エンコードした文字列を署名の入力に使う場合は話が変わります。署名する側と検証する側が、バイト単位で一致しなければならないからです。だからこそ、AWS SigV4もOAuth 1.0aも独自のエンコーダを仕様として定めています。その状況にあるなら、strictモードを有効にしてください。
base64urlとbase64は同じものですか?
アルゴリズムは同じで、字母が違います。base64が使う+と/は、URLではどちらも意味を持つ文字なので、base64urlはこれらを-と_に置き換え、クエリ文字列で意味を持つ=のパディングも取り除きます。JWTは3つのbase64urlセグメントをドットでつないだものであり、JWTをそのままURLに貼れるのにbase64の塊は貼れないのはこのためです。
入力はアップロードされますか?
いいえ。エンコードとデコードは、ブラウザ内で動く文字列操作です。Networkパネルを開いたまま、入力しながら見てみてください。ここでそれが重要なのは、デコードが必要になるURLには、署名付きのコールバックURL、OAuthのリダイレクト、セッショントークンが頻出するからです。つまり、無害に見える形をした認証情報だということです。