パーセントエンコーディング: 予約文字と二重エンコードのバグ
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 エンコーダ/デコーダ を使ってください。