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 符号化は特殊文字を百分率に置き換えるだけの話ではありません。位置が規則を決め、パスとクエリは別物で、時機が成否を分けます。

← 記事一覧に戻る

コメント

…