本文へスキップ

RFC 3986とRFC 2396におけるURLエンコードの違い

約 7 分
AIで生成された記事

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全体なのか、パスやクエリの値なのか、フォームデータなのかを先に決めてからエンコードすることが大切です。

参考

おすすめの記事

最新の記事

著者について

404

s-yoshiki

ただの備忘録です。

※外部送信に関する公表事項