パーセントエンコーディング: 予約文字と二重エンコードのバグ

URL のパーセントエンコーディングがどう動くか、なぜ空白がパスでは %20 でフォーム本文では + なのか、encodeURIComponent vs encodeURI、そして二重エンコードのバグがどう %2520 を生むのかを整理します。

URL は限られた ASCII 文字の集合しか含められません。その集合の外にあるもの、 つまり空白、非ラテン文字、または URL 構文が構造のために予約する文字の 1 つは、 パーセントエンコーディングで表現されなければなりません。% の後にバイトを表す 16 進数 2 桁が続く形です。バイト列は、文字をまず UTF-8 でエンコードし、次に各 バイトを %XX として書いたものから来ます。だから空白 (バイト 0x20) は %20 になり、é (UTF-8 バイト 0xC3 0xA9) は %C3%A9 になります。この記事 では、どの文字がエンコードを必要とするか、フォーム処理を壊す %20 vs + の あいまいさ、誤用される JavaScript の関数、そしてエンコードが複数の層で起きる ときに現れる二重エンコードのバグを扱います。

URL がそれを必要とする理由

RFC 3986 は URI の文法を定義し、その文法は小さく固定された文字の集まりを許可 します。文字は 3 つのグループに分かれます。

  • 非予約 (unreserved) — 常に安全で、決してエンコードを必要としない: A-Z a-z 0-9 - . _ ~
  • 予約 (reserved) — URL で合法だが構造的な意味を持つので、区切り ではなく 値の中 に現れるときにエンコードされなければなりません。
  • それ以外のすべて — 空白、制御文字、そしてすべての非 ASCII。生の URL に 居場所がなく、エンコードされなければなりません。

予約文字が厄介なグループである理由は、同じバイトが位置によって区切りにも データにもなりうるからです。パスセグメントの間の / は構造で、1 つの セグメントの値の中の / はデータなので %2F にならなければなりません。

予約文字

これらは、値の中に生で残ると URL の意味を変える文字です。データの一部であるとき エンコードしてください。

文字 エンコード 構造的な役割
空白 %20 生では不正、多くのパーサで URL を終わらせる
? %3F クエリ文字列を開始
# %23 フラグメントを開始
& %26 クエリパラメータを区切る
= %3D キーと値を区切る
/ %2F パスセグメントを区切る
+ %2B フォームエンコードのデータで空白を意味する
% %25 パーセントエスケープを開始する

+% の行が、静かな破損を起こすものです。エンコードを忘れたクエリ値の中の 文字どおりの + は、フォームデコードをする何かに空白として読まれます。%25 に エンコードされていない文字どおりの % は、エラーになるか、もっと悪い場合は 意図されていないエスケープの開始として解釈されます。

%20 vs + のあいまいさ

これはエンコードのバグの最もよくある源で、空白をどう表現するかについて異なる 2 つの仕様が食い違うことから来ます。

  • 一般的な URI では、つまりパスと RFC 3986 のもとでのクエリ文字列では、 空白は %20 です。それで終わりです。
  • application/x-www-form-urlencoded のデータでは、つまり HTML フォームの POST の本文と、長い慣習でフォームフィールドを運ぶクエリ文字列では、空白は + です。その文脈で文字どおりの +%2B です。

だから q=hello world は、どちらか一方として正しく現れえます。

?q=hello%20world      一般的な URI ルール
?q=hello+world        フォームエンコードのルール

どちらも有効です。重要なのは、エンコーダとデコーダが合意していることです。バグ は、一方が空白を + にエンコードし (フォームのルール)、もう一方が一般的な URI ルールでデコードして文字どおりの + がデータに残るとき、または本物の + を 含む値 (電話番号 +1 555...c++ の検索) が、+ を空白に変える何かによって デコードされるときに現れます。

実用的なルールです。両端を制御していてクエリ文字列を手で組み立てるなら、%20 を選び、文字どおりの +%2B にエンコードしてください。本物のフォーム本文を 送信するなら、ブラウザは + を使い、あなたのサーバーのフレームワークはそれを 期待します。

JavaScript の encodeURIComponent vs encodeURI

JavaScript は 2 つのエンコーダを提供し、別々の仕事のために存在します。

  • encodeURI(url) は、すでに構造的に完成した URL 全体 をエンコードする ためのものです。予約された構造文字 : / ? # [ ] @ & = + $ , とその他いくつかを そのまま残します。それらが区切りとして仕事をしているからです。
  • encodeURIComponent(value) は、URL に落とされる データの一片、つまり パスセグメント 1 つ、クエリ値 1 つをエンコードするためのものです。予約された 構造文字もエンコードします。値の中ではそれらは構造ではなくデータだから です。
encodeURI("https://x.com/a b?q=c/d&e=f")
// "https://x.com/a%20b?q=c/d&e=f"   (スラッシュ、?、& はそのまま)

encodeURIComponent("c/d&e=f")
// "c%2Fd%26e%3Df"                   (すべてエスケープ)

個々のクエリ値とパスセグメントすべてには encodeURIComponent を使ってください。 encodeURI は、完成した URL 文字列を持っていて、その構造に触れずに、さまよう 空白と非 ASCII だけをエスケープしたいときにだけ使ってください。ほとんどの人が 想定するより狭い用途です。

1 つの罠です。encodeURIComponent+ をエンコード しません+ は 非予約に見える文字なので、そのまま残します。一般的な URI ルールのもとでは 問題ありませんが、あなたのサーバーがクエリ文字列をフォームのルールでデコード すると、エンコードされていない + は空白になります。フォームデコードの エンドポイントを狙うときは後処理してください。

encodeURIComponent("a+b").replace(/%20/g, "+")  // フォーム形式
encodeURIComponent("a+b").replace(/\+/g, "%2B")  // 文字どおりの + を保護

デフォルトに頼らず、1 つの慣習を意図的に選んでください。

パスとクエリを別々にエンコードする

パスとクエリ文字列は異なる予約集合を持つので、文字列全体に 1 つの関数を走らせる より、別々にエンコードしてください。パスセグメントでは / は区切りで、1 つの セグメントの値の一部であるとき %2F にならなければなりません。+= は 普通のデータです。クエリでは &= は区切りで、値の中ではエンコードされ なければなりません。/ は通常生で許可されます。

安全な方法は、すでにエンコードされた部品から URL を組み立てることです。各パス セグメントと各クエリ値に個別に encodeURIComponent を走らせ、次にあなたが制御 する生の区切りでつなぎます。組み立てた文字列を 2 度目にエンコードしないで ください。まさにそこから次のバグが来ます。パス用の URL セーフな識別子 (エスケープ % がまったくない) が本当に必要なら、私たちの URL スラッグ生成器 でスラッグに正規化してください。

二重エンコードのバグ

% 自体が %25 にエンコードされるので、すでにエンコードされた文字列に エンコーダを走らせると壊れます。hello world は一度エンコードされて hello%20world になります。その結果をもう一度エンコードすると、%20 の中の %%25 になり、hello%2520world を生みます。その文字列を一度デコードすると、 hello world ではなく文字どおりのテキスト hello%20world を得ます。

探すべき兆候は、本来は単一のエスケープであるべきものの後の %25 です。

hello world      元
hello%20world    一度エンコード  (正しい)
hello%2520world  二度エンコード (バグ — %20 が %2520 になった)
hello%252520...  三度エンコード

これは、エンコードが複数の層で走り、誰も文字列が何回触られたかを追跡しない たびに起きます。フロントエンドがクエリ値をエンコードし、API ゲートウェイや リバースプロキシが転送された URL を再エンコードし、バックエンドのフレームワークが 保存やリダイレクトの前に 3 度目にエンコードします。各層は個別には「正しく」、 スタックは間違っています。

検出するには、疑わしい値を一度デコードし、結果にまだ %XX のエスケープが あるかを確認してください。単一のデコードがデータに見える %20%2F を 残すなら、少なくとも二度エンコードされています。解は 2 度目の replace では なく構造的です。正確に一度、生のデータが URL になる境界でエンコードし、その後は どこでも値を不透明に扱ってください。層を足すのではなく剥がしてください。二重 エンコードを二度デコードして「直さ」ないでください。%25 を正当に含む値が、 その余分なパスで壊れるからです。

これは他の転送エンコーディングで人々をつまずかせるのと同じ規律です。1 つの層が エンコードされたデータを平文と取り違える失敗の形は、 Base64 は暗号化ではない の混同も 引き起こします。

まとめ

パーセントエンコーディングは、安全でない文字を % にその UTF-8 バイトの 16 進数を加えたものに写像します。予約文字は値の中に現れるたびにエンコードし、 空白は一般的な URI ルールでは %20 だがフォームのルールでは + だと覚え、 個々の構成要素には encodeURIComponent を、完成した URL にだけ encodeURI を 使い、%2520 の類いのバグを避けるために各値を正確に一度だけエンコードして ください。

値をエンコードまたはデコードして、どのバイトが変わるかを正確に見たいとき、そして 一層ずつデコードして二重エンコードを捕まえたいとき、私たちの URL エンコーダ/デコーダ を使ってください。