URL 符号化:空白・プラス・二重符号化の罠
クエリでは + が空白、パスでは + はプラスです。同じ文字でも位置によって規則が異なり、二度符号化すると一致しない値が静かに生まれます。
URL 符号化の規則は一つ(百分率と十六進二桁)ですが、三つの場所で慣習が異なります。混ぜると不具合になります。
罠 1:+ が空白を意味するのはクエリだけ
?a=b+c → 値は "b c" (フォーム符号化、+ は空白)
/a+b → パスは "/a+b" (パスでは + はプラス)
この非対称は歴史的なものです。クエリは application/x-www-form-urlencoded を引き継ぎ、パスは引き継ぎませんでした。
| 位置 | 空白 | プラス記号 |
|---|---|---|
| クエリ | + または %20 |
%2B 必須 |
| パス | %20 必須 |
+ をそのまま |
クエリで本当のプラスを送るには %2B に符号化しなければ、相手側で空白に復号されます。典型的な不具合です。
罠 2:パスとクエリで予約文字が違う
encodeURIComponent は / ? & = # を符号化し、encodeURI はしません。取り違えると次のようになります。
// パスの一部を符号化したい
encodeURIComponent('a/b'); // 'a%2Fb' ← 一区画
encodeURI('a/b'); // 'a/b' ← 二区画になる
// クエリの値を符号化したい
encodeURIComponent('a&b'); // 'a%26b' ← 正しい
encodeURI('a&b'); // 'a&b' ← 二つのパラメータと解釈
規則は単純です。部品には encodeURIComponent、URL 全体には encodeURI。 クエリを組み立てるなら常に前者です。
規格は ! ' ( ) * の符号化も求めますが、encodeURIComponent はこれを残します。
function strictEncode(text) {
return encodeURIComponent(text)
.replace(/[!'()*]/g, (ch) => '%' + ch.charCodeAt(0).toString(16).toUpperCase());
}
多くは無くても動きますが、OAuth のような署名計算ではこの数文字で不一致が出ます。
罠 3:二重符号化
既に符号化された文字列を再度符号化すると % が %25 になります。
元 a b
一度 a%20b ← 正しい
二度 a%2520b ← 一度復号すると "a%20b"、 "a b" ではない
二重符号化はエラーにならず、誤った値になるだけです。 原因は多くの場合、枠組みが一度符号化し、コードがもう一度符号化したことです。
見分け方は % の密度です。%25 があればほぼ二重です。復号側で「変化しなくなるまで復号する」のは禁物です。値が本来 % を含む場合、一度多い復号が元の値を壊します。
実用的な二つの規則
一:符号化は組み立ての最後に行う。
const q = params.map(([k, v]) =>
strictEncode(k) + '=' + strictEncode(v)
).join('&');
先に連結してから符号化すると区切り文字まで符号化されます。URLSearchParams は既に処理済みです。
new URLSearchParams([['a', 'b c'], ['d', '1+2']]).toString();
// 'a=b+c&d=1%2B2'
二:復号結果をパス連結に使わない。
%2E%2E%2F は ../ に復号されます。復号してからパスを連結するサーバは、ディレクトリ横断を許したことになります。正しい順序はパスを区画に分け、各区画を検査してから復号するか、復号結果に / や .. があれば拒否することです。
URL 符号化は特殊文字を百分率に置き換えるだけの話ではありません。位置が規則を決め、パスとクエリは別物で、時機が成否を分けます。

コメント
…