URLをパーセントエンコードするとき、! や * をエンコードする実装と、そのまま残す実装があります。
この違いを調べると、RFC 2396 と RFC 3986 の文字分類や、JavaScript の encodeURIComponent の仕様に行き着きます。
この記事では、2つのRFCで何が変わったのかを整理します。
先に結論
RFC 2396 から RFC 3986 への主な変更は、!、*、'、(、) の5文字の分類です。
RFC 3986 は RFC 2396 を置き換える仕様です。
| 文字 | RFC 2396 | RFC 3986 |
|---|---|---|
英数字、-、_、.、~ |
非予約文字 | 非予約文字 |
!、*、'、(、) |
非予約文字 | 予約文字(sub-delims) |
;、/、?、:、@、&、=、+、$、, |
予約文字 | 予約文字 |
この分類変更によって、5文字をパーセントエンコードした形と、文字のままの形を同じURIとして扱えるかどうかが変わります。
RFC 3986 では #、[、] も gen-delims として予約文字に列挙されています。# は RFC 2396 でもフラグメントの区切りとして使われ、角括弧はIPv6アドレスを囲む構文に使われます。
まず、パーセントエンコードの仕組み
どちらのRFCでも、パーセントエンコードは % と2桁の16進数でオクテットを表します。
空白文字 → %20
ここでいう「URLエンコード」は、厳密にはURIの構成要素に対するパーセントエンコードを指すことが多い言い方です。 RFC 2396 と RFC 3986 はURIの一般的な構文を定義しており、フォーム送信などで使われるすべてのエンコード方式をひとまとめに規定しているわけではありません。
RFC 2396では ! * ' ( ) は非予約文字
1998年の RFC 2396 では、非予約文字を英数字と mark の組み合わせで定義しています。
mark = - _ . ! ~ * ' ( )
そのため、RFC 2396 の分類では !、*、'、(、) は非予約文字です。これらは %21、%2A のようにエスケープしてもURIの意味は変わらない文字とされています。
たとえば、! と %21 は同じデータとして扱えます。
RFC 3986では5文字が予約文字になった
2005年の RFC 3986 では、非予約文字が次のように整理されました。
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~"
一方、!、*、'、(、) は sub-delims、つまり予約文字の一部になりました。
sub-delims = "!" / "$" / "&" / "'" / "(" / ")"
/ "*" / "+" / "," / ";" / "="
予約文字は、URIの構文や個別のスキームで区切りとして使われる可能性があります。値として文字そのものを渡したい場合は、URIを組み立てる際にパーセントエンコードします。
たとえば ! をデータとして扱うなら、RFC 3986 に沿った表現は %21 です。! のままにした文字列と %21 を含む文字列は、RFC 3986 では常に同じURIとは限りません。
RFC 3986での正規化にも影響する
RFC 3986 では、非予約文字をパーセントエンコードした表記と、その文字を直接書いた表記は同等です。たとえば ~ と %7E は同等として扱えます。
一方、予約文字については同じとは限りません。! は RFC 3986 で予約文字になったため、! と %21 を機械的に置き換えると、URIの解釈が変わることがあります。
そのため、完成済みのURLを一括でデコードしたり、パーセントエンコードされた文字をすべて元に戻したりするのは避けます。URLを構成要素に分けてから、値をエンコードまたはデコードする必要があります。
JavaScriptの encodeURIComponent で見える違い
JavaScript の encodeURIComponent は、英数字や - _ . ! ~ * ' ( ) をエンコードせずに残します。
encodeURIComponent("a!b*c'd(e)");
// "a!b*c'd(e)"
この挙動は RFC 3986 の非予約文字の範囲とは一致しません。RFC 3986 では予約文字になった5文字も、encodeURIComponent はそのまま残します。
RFC 3986 のルールで「URI構成要素に入れるデータ」をエンコードしたい場合は、残った5文字を追加でエンコードできます。
function encodeRFC3986Component(value) {
return encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
`%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
encodeRFC3986Component("a!b*c'd(e)");
// "a%21b%2Ac%27d%28e%29"
これはURIの構成要素へ渡す「データ」をエンコードする例です。完成したURL全体に適用すると、/ や ? など構文上必要な文字までエンコードしてしまいます。
クエリ文字列ではフォームエンコードと区別する
HTMLフォーム由来の application/x-www-form-urlencoded では、空白を + として表す慣習があります。これに対して、encodeURIComponent は空白を %20 にします。
encodeURIComponent("hello world");
// "hello%20world"
+ を空白として読むか、プラス記号そのものとして読むかは、受け取る側の方式によります。RFC 3986 のパーセントエンコードと、フォーム用のエンコードを同じものとして扱わないようにします。
使い分けの目安
- URIの構造を作るときは、値を個別の構成要素としてエンコードしてから組み立てる
- 文字列をURI構成要素のデータとして扱い、RFC 3986 の分類にそろえる場合は
!'()*もエンコードする - クエリをフォームとして送受信する場合は、空白や
+の扱いをapplication/x-www-form-urlencodedに合わせる - 完成したURIを一括でデコードしない。特に予約文字を含むエスケープは、区切りの意味を変える可能性がある
まとめ
RFC 2396 と RFC 3986 のエンコード表現の違いとして、まず押さえたいのは ! * ' ( ) の5文字です。RFC 2396 では非予約文字、RFC 3986 では予約文字の sub-delims に分類されます。
パーセントエンコードの %HH という形式自体が変わったのではなく、「どの文字を安全なデータとして扱えるか」の分類が変わりました。また、JavaScript の encodeURIComponent はこのRFC 3986の分類と完全には一致しません。対象がURL全体なのか、パスやクエリの値なのか、フォームデータなのかを先に決めてからエンコードすることが大切です。