URLエンコーダーにはencodeURIencodeURIComponentという2つのメソッドがあり、「全部encodeURIComponentでエンコードすればいい」と思っている人は多いはずです。実際、ほとんどの場面ではその理解で問題になりません。 ところが「クエリパラメータの値を1つだけエンコードする」という、Webアプリケーションでもっとも頻繁に発生する場面に限っては、encodeURIを使うとURLの構造そのものが壊れます。なぜ壊れるのか、どこまでが安全でどこから危険なのかを、実際に崩れる例で確認します。

encodeURIとencodeURIComponentがエンコードしない文字の違い

両方の関数はアルファベット・数字・- _ . ! ~ * ' ( )をエンコードしません。ここまでは共通です。 違いはそれ以外の文字、具体的には; / ? : @ & = + $ , #の扱いにあります。encodeURIはこれらをエンコードせずそのまま残しますが、encodeURIComponentはすべてパーセントエンコードします。

encodeURI('a=1&b=2 3')          // "a=1&b=2%203"
encodeURIComponent('a=1&b=2 3') // "a%3D1%26b%3D2%203"

encodeURIがスペースだけを%20に変換し、=&をそのまま通している点に注目してください。これは仕様としては正しい動作です。encodeURIは「URI全体(スキーム・ホスト・パス・クエリ文字列をまとめたもの)」を渡すことを想定した関数で、= ? & #のような文字はURIの構造を作るデリミタなので、わざとエンコードせずに残しています。

なぜ「値1つだけ」のエンコードで壊れるのか

問題は、Webアプリケーションで実際にエンコードしたいのは「URI全体」ではなく「クエリパラメータの値1つ」だというところにあります。 ユーザーが検索ボックスにC++ & Javaと入力したケースを考えます。これをqパラメータとしてURLに組み込む処理を、誤ってencodeURIで書いてしまうとどうなるか確認します。

const value = 'C++ & Java';

encodeURI(value)          // "C++%20&%20Java"
encodeURIComponent(value) // "C%2B%2B%20%26%20Java"

encodeURIの結果には&がそのまま残っています。これをhttps://example.com/search?q= の後ろに連結すると、&がクエリ文字列のセパレータとして解釈されてしまい、パラメータが2つに分裂します。

new URL('https://example.com/search?q=C++%20&%20Java').searchParams
// q     = "C   "
// " Java" = ""

qの値はC++ & JavaではなくCで途切れ、後半のJavaは値を持たない別パラメータとして切り離されます。+application/x-www-form-urlencodedの文脈ではスペースとして解釈されるため、結果的にqの値は先頭のCと末尾の空白だけになります。一方encodeURIComponentでエンコードした場合は、&+もパーセントエンコードされているのでパーサーがデリミタと誤認することはなく、q=C++ & Javaが1つの値として正しく復元されます。

「URI全体」と「コンポーネント」を区別する

この違いを整理すると、判断基準は次のようになります。

  • 渡す文字列がすでにスキームやパスを含む完成したURI(https://example.com/search?q=Javaのような形)なら、構造を保ったままエンコードしたい場面でしか使わないencodeURIが候補になります。ただしこの場合も、すでに正しくパーセントエンコードされた%xxはそのまま保持される一方、新たに追加された&=はエンコードされないという中途半端な挙動になるため、実務であえて使う場面はほとんどありません。
  • 渡す文字列がパスの1セグメントやクエリパラメータの値1つなど、URIの「部品」ならencodeURIComponentを使います。Webアプリケーションでユーザー入力をURLに埋め込む処理は、ほぼ例外なくこちらに該当します。

url-encoderツールのヘルプにある「ONの場合はencodeURIComponentを使用し、スラッシュや疑問符などのURL記号も含めてすべてエンコードします」という説明は、まさにこの「部品」としてのエンコードを指しています。逆に「OFFの場合はencodeURIを使用し、URL構造を維持したままエンコードします」は、文字列がすでにURI全体の形をしていることが前提になっている点に注意が必要です。

二重エンコードという、もう1つの落とし穴

encodeURIComponentを使えばこの問題は解決しますが、別の落とし穴として「すでにエンコードされた文字列を再度エンコードしてしまう」二重エンコードがあります。

const once = encodeURIComponent('100%');   // "100%25"
const twice = encodeURIComponent(once);    // "100%2525"

decodeURIComponent(twice);  // "100%25"(一度デコードしただけでは戻らない)
decodeURIComponent(decodeURIComponent(twice)); // "100%"(2回デコードしてようやく元に戻る)

%という文字自体が%25にエンコードされるため、エンコード済みの文字列をもう一度encodeURIComponentに通すと%%25に変換され、結果は%2525になります。これをデコードする側が1回しかデコードしないと、値は100%25のまま残り、100%という元の値に戻りません。 リダイレクト処理やAPIゲートウェイを経由する構成では、複数の場所でエンコード処理が行われがちです。「このURLの値は今エンコード済みなのか未エンコードなのか」を処理の境界ごとに意識しておかないと、二重エンコードは静かに発生します。

実際にどんな文字列がどちらの関数でどう変わるかを試したい場合は、以下のツールでencode/decodeを切り替えて確認できます。