9 URI のエスケープ
田中 哲
1. はじめに
Web において使われる URI(URL)には様々な情報が埋め込まれるが、その埋め込みの際にはエスケープ(パーセントエンコード)が行われる。埋め込まれた情報は取り出されるときにアンエスケープ(パーセントデコード)される。
たとえば、四則演算を行う電卓の Web アプリケーションを考える。この電卓は HTML の form でユーザに式の入力を求め、式が入力されてサーバに送られたらその結果を表示する。ここで、式は "http://calc.example.org?q=式" というような URI にブラウザがアクセスすることによって、サーバに送られるものとする。この形式は HTML の form で用いられる application/x-www-form-urlencoded という形式であり、HTML の規格で定義される。この場合、"1+1" という式を URI に埋め込むには "+" という文字を "%2B" にエスケープし、"http://calc.example.org?q=1%2B1" としなければならない。Web アプリケーション側では、URI から取り出した "1%2B1" をアンエスケープして "1+1" という式を得て計算を行うことになる。
ここで、エスケープとアンエスケープの処理が適合していないと、適切に情報を受け渡せない。たとえば上記の電卓で、送信側でエスケープを行わず "http://calc.example.org?q=1+1" とすると、application/x-www-form-urlencoded では "+" は空白(ASCII コード 0x20)を示すことになっているので、Web アプリケーションは "1 1" という文字列を受けとることになる。また、逆に Web アプリケーション側でアンエスケープを行わないと、"1%2B1" という不適切な式を受けとることになる。
エスケープ・アンエスケープの処理は様々な言語でライブラリが提供されている。ECMAScript (JavaScript) もそのひとつである。今期には ECMAScript 5th edition が承認され、そこでもエスケープ・アンエスケープのための 4つの関数が定義されている。そこで、本稿では URI のエスケープの考え方を解説し、ECMAScript の関数について論じる。
2. エスケープの考え方
エスケープは表現したい情報を制限された形の文字列で表現するために行われる。
たとえば、C 言語の文字列リテラルは任意のバイト列をプログラムというテキスト内に埋め込むために、バックスラッシュを用いたエスケープ記法を持っている。たとえば、NUL 文字それ自体をプログラム内に直接埋め込むのは不適切であるが、エスケープ記法を用いれば \0 という 2文字により、NUL 文字それ自体は使わずに NUL 文字を表現することができる。また、バックスラッシュや文字列終端を示すダブルクォートを除いた表示可能文字はそのまま記述することができ、多くの場合、表現したい情報をそのまま記述できる。
URI でも、パーセントエンコーディングというエスケープ記法により、表現したい情報を URI の文法に従って埋め込める。たとえば、# という文字は query と fragment の間の区切りとして用いられているので、その文字自体を query 内の文字として表現したい場合は、エスケープして %23 としなければならない。つまり、# という文字それ自体を query に直接埋め込むことはできないが、エスケープ記法を用いれば %23 という 3文字により、# という文字それ自体は使わずに # という文字を表現することができる。
2.1. URI の構造
URI は RFC 3986 で定義される。ただし、ここでは個々のスキーム(http, ftp, mailto など)は定義されず、それらについては別に定義される。
URI は資源の識別子を ASCII の表示可能文字のみからなる文字列で構成するようにデザインされている。ここで URI は紙に書かれることも考慮されており、そのため空白(0x20)は URI 中には使用できない。ASCII の表示可能文字は "!"(0x21)から "~"(0x7E)までの 94文字であるが、その 94文字の中でも、"^" などいくつかの文字は使用可能な文字から除かれている。使用できない文字はパーセントエンコーディングという %XX という形式を使って 16進のコードで記述する。
URI の文法を RFC 3986 から抜粋すると以下のようになる。この文法は ABNF [RFC2234] という記法によって記述されている。
ABNF は伝統的な BNF(Backus-Naur Form)とよく似ている。ただし、選択はスラッシュで表現する。また、繰り返しの記法として <a>*<b>element があり、これは element の <a> 回以上 <b> 回以下の繰り返しを意味する。そして、<a> が省略された場合には 0, <b> が省略された場合には無限大を意味する。また、省略可能であることを示す記法として [ elements ] があり、ブラケット内が省略可能であることを意味する。なお、終端記号としてダブルクォートでくくられた形の文字列を使用できるが、これは大文字小文字を区別しないことに注意が必要である。あと、セミコロンから行末まではコメントである。
URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) hier-part = "//" authority path-abempty / path-absolute / path-rootless / path-empty authority = [ userinfo "@" ] host [ ":" port ] userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) host = IP-literal / IPv4address / reg-name IP-literal = "[" ( IPv6address / IPvFuture ) "]" IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) reg-name = *( unreserved / pct-encoded / sub-delims ) port = *DIGIT path-abempty = *( "/" segment ) path-absolute = "/" [ segment-nz *( "/" segment ) ] path-rootless = segment-nz *( "/" segment ) path-empty = 0<pchar> ; 空文字列 segment = *pchar segment-nz = 1*pchar query = *( pchar / "/" / "?" ) fragment = *( pchar / "/" / "?" ) pchar = unreserved / pct-encoded / sub-delims / ":" / "@" unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" pct-encoded = "%" HEXDIG HEXDIG HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" DIGIT = %x30-39 ; 0-9 ALPHA = %x41-5A / %x61-7A ; A-Z / a-z |
ここで ALPHA, DIGIT, HEXDIG は ABNF [RFC2234] から抜粋したものである。ABNF においてダブルクォートでくくられた文字列は大文字小文字を区別しないため、HEXDIG は小文字の "a", "b", "c", "d", "e", "f" も受け付ける。
また、IPv4address, IPv6address はそれぞれ IPv4 と IPv6 のアドレスを示す。これらの定義は煩雑であり、またそれらの部分ではパーセントエンコーディングが使えず本稿のテーマから外れるためここでは示さない。
ここで、pchar を展開して整理すると、文法を 3つに分割できる。
-
URI の大きな構造を定義する部分
この部分により、URI が木構造として定義される。
URI = scheme ":" hier-part [ "?" query ] [ "#" fragment ] hier-part = "//" authority path-abempty / path-absolute / path-rootless / path-empty authority = [ userinfo "@" ] host [ ":" port ] host = IP-literal / IPv4address / reg-name IP-literal = "[" ( IPv6address / IPvFuture ) "]" IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" ) path-abempty = *( "/" segment ) path-absolute = "/" [ segment-nz *( "/" segment ) ] path-rootless = segment-nz *( "/" segment ) path-empty = 0<pchar> ; 空文字列
-
URI の要素の「文字列」を定義する部分
この部分は文字もしくはエスケープ(pct-encoded)が並んでいるという構造になっている。 つまり、C 言語の文字列リテラルから両端のダブルクォートを除いたような構造である。
ただし、scheme は単なる並びというわけではなく、先頭の文字が英字であるという制約がある。 また、前項目に入れた IPvFuture の一部にも文字列的な部分がある。
scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) port = *DIGIT userinfo = *( unreserved / pct-encoded / sub-delims / ":" ) reg-name = *( unreserved / pct-encoded / sub-delims ) segment = *( unreserved / pct-encoded / sub-delims / ":" / "@" ) segment-nz = 1*( unreserved / pct-encoded / sub-delims / ":" / "@" ) query = *( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" ) fragment = *( unreserved / pct-encoded / sub-delims / ":" / "@" / "/" / "?" )
-
各要素に使われる文字の種類と個々のエスケープを定義する部分
この部分は文字の集合として unreserved と sub-delims を定義し、URI で用いられるエスケープの記法として pct-encoded を定義している。
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" pct-encoded = "%" HEXDIG HEXDIG HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" ALPHA = %x41-5A / %x61-7A ; A-Z / a-z DIGIT = %x30-39 ; 0-9
ここで、文字列的な部分には segment や query などがあるが、使用できる文字種はそれぞれで異なる。scheme と port は pct-encoded が使えず表現できる情報に制約があるのに対し、それ以外は pct-encoded によって任意の文字(バイト)を表現できる。
たとえば、"@" は userinfo には使えないが、segment には使用できる。この違いは、それぞれが利用される文脈が原因である。userinfo は authority で利用されるが、そこでは userinfo と host が "@" によって区切られている。そのため "@" を使用可能にすると、"@" が userinfo の内容なのか host との区切りなのか解釈が曖昧になってしまう。それに対して、segment が利用される path-absolute などでは "@" を区切りとして用いておらず、segment に "@" を使用しても曖昧にはならない。つまり、userinfo の定義に "@" が含まれないのは、authority の構造が原因である。
同様に、この文法の中で使われている区切り文字には ":", "/", "?", "#", "[", "]", "@" があり、これらについても各文字列部分において問題が起きなければ許されている場合がある。
- scheme では pct-encoded は許されず、使用できる文字は英数字と "+", "-", "." と制約されている。 また、先頭文字はさらに英字だけに制限されている。
- port では pct-encoded は許されず、使用できる文字は数字のみである。これは、ポート番号は数字だけで表現できることがあきらかで、他の文字やパーセントエンコーディングを使用可能にする利点がないからだと考えられる。
- userinfo では ":" は許されるが、"/", "?", "#", "[", "]", "@" は許されない。
- もし "/" が許された場合、"http://user/info@host" は authority が "user/info@host" と"user" のどちらなのか曖昧になる。後者の場合、path-abempty が "/info@host" になる。
- もし "?" が許された場合、"http://user?info@host" は authority が "user?info@host" と"user" のどちらなのか曖昧になる。後者の場合、query が "info@host" となる。
- もし "#" が許された場合、"http://user#info@host" は authority が "user#info@host" と "user" のどちらなのか曖昧になる。後者の場合、fragment が "info@host" となる。
- もし "@" が許された場合、"http://user@info@host" はuserinfoとhostの区切りがわかりにくい。ただし、host には "@" が許されていないので、userinfoだけに "@" を許すのは曖昧ではない。
- "[" と "]" は IPv6 アドレスを区切るためにしか許されておらず、userinfoは IPv6 アドレスが記述される host とは "@" で区切られている。そのため、"[" と "]" を許しても曖昧にはならない。
- reg-name では ":", "/", "?", "#", "[", "]", "@" は許されない。reg-name は登録されたホスト名の意味であり、ホスト名・ドメイン名を表現するのに使われる。なお、ホスト名・ドメイン名には使用できる文字に制約があるが、この文法はその制約を表現していない。
- もし ":" が許された場合、"http://host:80" はhostが "host:80" と "host" のどちらなのか曖昧になる。後者の場合、port が "80" となる。
- もし "/" が許された場合、"http://ho/st" はhostが "ho/st" と "ho" のどちらなのか曖昧になる。後者の場合、path-abempty が "/st" となる。
- もし "?" が許された場合、"http://ho?st" はhostが "ho?st" と "ho" のどちらなのか曖昧になる。後者の場合、query が "st" となる。
- もし "#" が許された場合、"http://ho#st" はhostが "ho#st" と "ho" のどちらなのか曖昧になる。後者の場合、fragment が "st" となる。
- もし "@" が許された場合、"http://userinfo@ho@st" はuserinfoとhostの区切りがわかりにくい。ただし、userinfo には "@" が許されていないので、host だけに "@" を許すのは曖昧ではない。
- もし "[" と "]" が許された場合、"http://[v1.xxxx]" は reg-name(登録された名前)が "[v1.xxxx]" なのか、IPvFuture (未来の IP アドレス) が "v1.xxxx" なのか曖昧になる。
- segment と segment-nz では ":", "@" は許されるが、"/", "?", "#", "[", "]" は許されない。segment と segment-nz は階層的な構造を表現する部分であり、ファイルシステムを URI にマップする時に、ディレクトリ階層の個々のファイル名を対応させるなどと使われる。
- もし "/" が許された場合、"http://host/foo/bar" は "foo/bar" が単一のsegmentなのかふたつの segment なのか曖昧になる。
- もし "?" が許された場合、"http://host/foo?bar" は segment が "foo?bar" と "foo" のどちらなのか曖昧になる。後者の場合、query が "bar" となる。
- もし "#" が許された場合、"http://host/foo#bar" は segment が "foo#bar" と "foo" のどちらなのか曖昧になる。後者の場合、fragment が "bar" となる。
- "[" と "]" は IPv6 アドレスを区切るためにしか許されておらず、segment と segment-nz は IPv6 アドレスが記述される host とは "/" で区切られている。そのため、"[" と "]" を許しても曖昧にはならない。
- query では ":", "@", "/", "?" は許されるが、"#", "[", "]" は許されない。
- もし "#" が許された場合、"http://host?foo#bar" は query が "foo#bar" と "foo" のどちらなのか曖昧になる。後者の場合、fragment が "bar" になる。
- "[" と "]" は IPv6 アドレスを区切るためにしか許されておらず、query は IPv6 アドレスが記述される host とは "?" で区切られている。そのため、"[" と "]" を許しても曖昧にはならない。
- fragment では ":", "@", "/", "?" は許されるが、"#", "[", "]" は許されない。
- もし "#" が許された場合、"http://host?query#foo#bar" は query と fragment の区切りがわかりにくい。ただし、query には "#" が許されていないので、fragment に "#" を許すのは曖昧ではない。なお、fragment に "#" が許されていないことにより、URIの最後から左に向かって最初の "#" を探すことにより fragment の範囲を決定できる。
- "[" と "]" は IPv6 アドレスを区切るためにしか許されておらず、fragment は IPv6 アドレスが記述される host とは "#" で区切られている。そのため、"[" と "]" を許しても曖昧にはならない。
なお、"[" と "]" が IPv6 アドレスを区切るためだけに使われているのは、RFC 2396では IPv6 アドレスが許されておらず、RFC 2732でその部分が拡張されたためと考えられる。
2.2. スキーム依存な URI の構造
前節で述べた URI の構造は RFC 3986 で定義される、すべての URI に共通する構造である。
個々のスキームでは URI の中にさらに構造を導入できる。たとえば、ftp URI は RFC 1738 で定義されるが、転送にバイナリモードを使うことを指示するのに "ftp://host/dir1/dir2/filename;type=i" というように、";type=i" という指定を使用できる。URI の構文からすると "filename;type=i" の部分が segment に対応する。つまり、segment の中身を ";" で区切って、転送のモードを指定するという規則を ftp スキームが定義している。
このように、各スキームでは segment をさらに区切って構造を定義できる。このように構造を定義できる対象は segment に限らず、文字列的な構造の部分であればどこでも可能である。
ここで追加された区切り文字を区切りでなく使いたい場合、パーセントエンコーディングを用いる。たとえば、上記の ftp の例で filename 内に ";" を使いたければ、"%3B" を用いる。
従って、ftp URI の filename の中では ";" と "%3B" の意味は異なる。このように、ある文字とそのパーセントエンコーディング表現で意味を異なるものとしてよい文字は RFC 3986 で決まっており、どの文字でもそうしてよいわけではない。以下に示す unreserved に属する文字はそのように意味を異ならせてはならない。つまり、"0" と "%30" の意味は常に一致しなければならない。
unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" |
それに対し、以下の reserved に属する文字は意味を異ならせてもよい。
reserved = gen-delims / sub-delims gen-delims = ":" / "/" / "?" / "#" / "[" / "]" / "@" sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" |
ここで reserved は gen-delims と sub-delims にわかれているが、gen-delims は RFC 3986 で定義される URI 一般の部分で使われている区切りであり、sub-delims はそれ以外の区切りである。
各スキームでは文法として許される範囲で reserved の文字を区切りとして採用できる。たとえば、segment の中で ";" を区切りとして構造を定義することはできるが、"/" を区切りとすることはできない。これは、segment 自体が "/" で区切られた構造であるため、その中で "/" を区切りとすることは曖昧になってしまうからである。このことは、segment の文法で "/" が許されていないことからもわかる。
また、区切りを定義するのはスキームだけとは限らない。http では、Web ブラウザは URI をエスケープされたままの形で Web サーバに送信する。つまり http というプロトコル自体はパーセントエンコーディングかどうかという違いの意味を定義しない。そのため、パーセントエンコーディングの使い方はブラウザとサーバの合意次第ということになる。
この合意のひとつの例が HTML の form である。HTML の form を submit する方法のひとつ
に form に入力した内容を application/x-www-form-urlencoded という形式でまとめたものを URI の query とし、その URI を http でリクエストするというものがある。
この application/x-www-form-urlencoded では "k1=v1&k2=v2" などというように、キーと値のペアのリストを "=" と "&" で query としてまとめる。ここでキーや値に "=" や "&" を使いたい場合、パーセントエンコーディングにより "%3D" や "%26" を用いる。また、"&" のかわりに ";" を使えることも推奨されているので、";" という文字自体には "%3B" を用いる。これは、URI が規定しなかった query 内の文字の意味を application/x-www-form-urlencoded という形式で定義し、Web ブラウザと Web サーバ(Web アプリケーション)がform の submit における情報伝達にその定義を採用しているとみなせる。
なお、application/x-www-form-urlencoded には空白(0x20)を表現するのに "+" を用い、"+" を表現するには "%2B" を用いるという規則もある。
この "+" による空白は application/x-www-form-urlencoded で定義されているものなので、URI の application/x-www-form-urlencoded 以外の部分 (segment など) では使えない。
そのような部分には segment など query 以外の部分がある。
また query でも application/x-www-form-urlencoded でない用途に使われている場合は、application/x-www-form-urlencoded の規則は適用されない。
3. ECMAScript の URI 関数
ECMAScript では以下の4つの関数を URI のエスケープ・アンエスケープ用として提供している。
- encodeURI
- decodeURI
- encodeURIComponent
- decodeURIComponent
どの関数も文字列を受け取り、文字列を返す。なお JavaScript の文字列は Unicode の文字列であり、UTF-16 による表現が想定されている。
encodeURI と decodeURI は URI 全体に適用するもので、encodeURIComponent と decodeURIComponent は URI の要素に適用するものと意図されている。
これらの関数は ECMAScript 3rd edition で導入された関数である。そのため、URI の仕様として RFC 3986 でなく、古い RFC 2396 を参考に設計されている。本稿では、RFC 3986と照らし合わせて論じるが、RFC 2396 の影響についてもその都度述べる。
それぞれの関数の動作を以下に述べる。
3.1. encodeURI
encodeURI は URI 全体を文字列として受け取って、エスケープして返す関数である。具体的には、引数中の文字で以下の文字以外は UTF-8 としてパーセントエンコードした文字列を返す。
- 英数字 (A-Z a-z 0-9)
- - . _ ~
- : / ? # @
- ! $ & ' ( ) * + , ; =
URI の定義と照らし合わせると、この文字のリストは unreserved および reserved から "[", "]" を除いたものである。ここでとくに、"%" はあげられていないため、encodeURI("%") は "%25" となる。
このため、生成できない URI が存在する。たとえば、encodeURI("#") は "#" がリストに含まれているため "#" となる。encodeURI("%23") は "%" がリストに含まれていないため "%2523" となる。
つまり、encodeURI は "%23" を含む結果は生成できない。("#" の ASCII コードは 0x23 で、"%" の ASCII コードは 0x25 である。)このように生成できないURIが存在するため、encodeURI は任意の URI を構成する用途には使えない。
そもそも、URI は構成した時点で区切りが曖昧にならないようにエスケープが必要であり、URI を構成した後でエスケープするというのは不適切である。
3.2. decodeURI
decodeURI は URI 全体を文字列として受け取って、アンエスケープして返す関数である。
具体的には、引数中のパーセントエンコーディングをUTF-8としてアンエスケープするが、アンエスケープした結果が以下の文字のどれかのときはアンエスケープせずにパーセントエンコーディングのまま残す。
- : / ? # @
- $ & + , ; =
URI の定義と照らし合わせると、この文字のリストは gen-delims から "[", "]" を除いたものおよび sub-delims の一部である。
ここで、decodeURI("%23") と decodeURI("%2523") はどちらも "%23" となる。前者は "%23" をアンエスケープした "#" がリストに入っているためであり、後者は "%25" をアンエスケープした "%" がリストに入っていないためである。つまり、decodeURI を適用すると、元の URI 内にあった "%23" と "%2523" を区別できなくなる。
decodeURI は encodeURI の結果を逆変換するのに使用できる。ただし、この逆変換には後述する decodeURIComponent も使用できる。decodeURIComponent は decodeURI よりも単純な動作の関数であり、逆変換のためには上記のパーセントエンコーディングを残す動作は不要である。
そもそも、URI は URI 全体をひとつの文字列とする限り区切りが曖昧にならないようにエスケープが必要であり、URI のままアンエスケープするというのは不適切である。
3.3. encodeURIComponent
encodeURIComponent は文字列を受け取って URI の要素として適切なエスケープを行って返す関数である。具体的には、引数中の文字で以下の文字以外は UTF-8 としてパーセントエンコードした文字列を返す。
- 英数字 (0-9 A-Z a-z)
- - . _ ~
- ! ' ( ) *
URI の定義と照らし合わせると、この文字のリストは unreserved および sub-delims の一部である。
この関数が対象としている URI の要素というのは、URI 中の文字列的な部分と考えられる。
つまり、ある文字列を URI を通して伝達する場合、送信側で文字列を encodeURIComponent でエスケープして URI に埋め込み、受信側で URI から取り出して decodeURIComponent でもとの文字列を得る。この埋め込み・取り出しが確実に行えるためには、URI で用いられる区切り文字が encodeURIComponent の結果に現れてはならない。もし区切り文字が現れると、本当の区切り文字と encodeURIComponent の結果内のものを誤認して、埋め込んだものが取り出せない可能性が発生する。
URI 内で実際に区切り文字として使われている文字は、URIの各箇所において異なるが、unreserved の文字は区切り文字としては使ってはならないので、encodeURIComponent の結果に使うことは問題ない。しかし、上記のリストにある sub-delims の文字は区切り文字として使うことが許されているため、情報の伝達に問題が生じる可能性がある。
なお、上記の "!", "'", "(", ")", "*" が sub-delims の文字であるが、これらは RFC 2396 では unreserved に属していた。そのため、リストにこれらの文字が入っているのは不思議ではない。
3.4. decodeURIComponent
decodeURIComponent は URI の要素を文字列として受け取って、アンエスケープして返す関数である。具体的には、引数中のパーセントエンコーディングを UTF-8 としてアンエスケープする。この関数は decodeURI と異なり、すべてのパーセントエンコーディングを例外なくアンエスケープする。
この関数は他の関数と異なり、例外的な動作の無い、単純な仕様になっている。
decodeURIComponent は encodeURI と encodeURIComponent の両方の逆変換に使用できる。これは decodeURIComponent がすべてのパーセントエンコーディングをアンエスケープするためである。encodeURI と encodeURIComponent の違いはパーセントエンコードする文字の集合の違いであるが、decodeURIComponent はどの文字でもエスケープされていればアンエスケープするので逆変換となる。
3.5. ECMAScriptのURI関数の問題
この節では ECMAScript で提供されている 4 つ URI 関数を説明し、問題点を指摘した。まとめると、以下のようになる。
- encodeURI, decodeURI: URI 全体をエスケープ・アンエスケープするという考え方がおかしい
- encodeURIComponent: URI の最新の仕様に追随しておらずエスケープが足りない
- decodeURIComponent: 問題なし
4. ありえたかもしれない仕様
前節で述べたように、ECMAScript の関数にはいくつかの問題がある。この節では、パーセントエンコーディングを扱う関数の仕様について論じる。
4.1. パーセントエンコーディングでエスケープする文字種
エスケープを行う関数では、どの文字をエスケープするかが問題となる。このことで考慮すべき要素には以下のものがある。
- 人間が URI を読みやすいように、なるべくエスケープしないほうがよい
- 伝えたい情報から URI に埋め込む形に変換する場合、"%" は常にエスケープしなければならない
- エスケープした結果を埋め込む箇所において許されていない区切り文字は使ってはならない
- URI を定義する RFC は改版を重ねるに従って reserved は多くなっている
まず、URI は人間にとって覚えやすいものであるようにデザインされているため、その趣旨を考えると、なるべくエスケープはしないほうが良い。エスケープをすると人間には内容を理解することが困難になる。たとえば、冒頭の電卓の例では、"1+1" という文字列を "1%2B1" とエスケープする必要があったが、普通は "1%2B1" が "1+1" であると読み取ることは難しい。
しかし、"%" をエスケープするのは、アンエスケープによって元の情報を取り出すためには必須である。もし、"%" をエスケープしなければ、"%23" は "#" をエスケープした結果なのか、もともと "%23" だったのか曖昧になり、エスケープ前の情報を再現できなくなってしまう。
また、URI で使われている区切り文字を使うのも同様に曖昧な結果をもたらす。もし、URI の query 部分に埋め込む文字列を生成するのに "#" をエスケープしない変換を用いると、生成された "http://host/?foo#bar" という URI からもとの文字列は得られない。これはもとの文字列が "foo" と "foo#bar" のどちらの可能性も否定できないためである。
ここで、URI の区切り文字は URI の各部分によって異なるが、区切り文字になりうる文字は reserved に属する文字だけである。この理由は、unreserved に属する文字はパーセントエンコードするかどうかで意味を変えてはならないため、それを区切り文字とすると、区切りではない文字としてその文字を表現することが困難になるからである。
そして、URI の仕様は RFC の改版にともなって、reserved に属する文字が増える傾向にある。たとえば、"!" は RFC 2396 で unreserved に属していたが、RFC 3986 では reserved に属している。
これに関しては後述する。
これらの点を考慮すると ECMAScript の encodeURIComponent は RFC 2396 の古い定義の reserved/unreserved を使っているいうことを除けば、良い仕様であるといえる。 unreserved 以外をすべてエスケープするというのは、reserved をすべてエスケープすることになるため、URI のどの箇所に埋め込んでもそこで使っている区切り文字と衝突することがない。また、unreserved をエスケープしないことにより、人間にとっての可読性を最大限保っている。もちろん "%" もエスケープするので、元の情報を確実に取り出すことができる。
唯一の問題は、RFC の変化に対応できなかったことである。これに対応するには RFC 2396 の unreserved ではなく、RFC 3986 の unreserved を使って動作を定義すれば良い。または、将来 RFC が変化してさらに文字が unreserved から reserved へ移ることを想定するなら、(さすがに英数字は unreserved のままだと期待して)unreserved のかわりに英数字のみをエスケープせずに残すという仕様も考えられる。
4.2. エスケープしたものはエスケープ済みだと分かるようにする
URI に関わるセキュリティ問題として、多重エスケープ・アンエスケープがある。これは、パーセントエンコード・デコードを複数回適用してしまうというものである。
これが問題になるのは、不適切な入力の検査に失敗することがあるためである。
たとえば、ディレクトリトラバーサルという脆弱性を防ぐために必要な検査のひとつに、URI に含まれている segment が ".." というファイル名でないことを調べる検査がある。これは、一般にファイル名は "%" が任意に含まれるなど、segment としては適切でないため、segment はファイル名をエスケープしたものとなる。ここで検出の対象である ".." はファイル名であるので、segment をまずアンエスケープしてファイル名に変換し ".." と比較するというのが正しい方法である。もし、アンエスケープせずに segment のまま ".." と比較すると、"%2E.", ".%2E", "%2E%2E" を検出できない。これらはアンエスケープすると ".." となるので、この間違いはディレクトリトラバーサル脆弱性の原因となる。
ここで、正しい方法を使っても、つまりアンエスケープしてから比較しても、まだ注意しなければならないことがある。比較した後でさらにアンエスケープしてはならないという点である。これが多重アンエスケープの問題である。もし、これを行ってしまうと、segment として "%252E%252E" が与えられた場合に、アンエスケープして "%2E%2E" が得られ、これは ".." とは異なるので問題は検出されないが、それをもう一度アンエスケープした ".." をファイル名として使ってしまう。
この間違いがおこるのは、プログラム内で、アンエスケープの前も後も単なる文字列であり、その区別が明らかでないからである。とくに、"abc" などエスケープ・アンエスケープで変化しない文字列は、値をみてもエスケープ済みかどうかわからない。このため、"%2E%2E" など、一回エスケープすると ".." などのアンエスケープで変化しない文字列になるデータでテストしても、多重アンエスケープ問題は発見できない。
この類の間違いを発見しやすくするデザインとしては以下のようなものが考えられる。
-
application/x-www-form-urlencoded 専用のエスケープ・アンエスケープ関数を作る
application/x-www-form-urlencoded は "K1=V1&K2=V2&..." という形式である。
K1, V1, K2, V2, ... は先に述べたようにエスケープされていなければならない。
ここで、K1,K2, ... は HTML form 内のコントロール名であり、V1, V2, ... は対応する値である。
ここで、エスケープをする前のコントロール名と値のペアのリスト [[k1, v1], [k2, v2], ...] を受け取り、各文字列をエスケープした後で "=" と "&" で連結して "K1=V1&K2=V2&..." という形式の文字列を生成する関数が考えられる。この関数を仮に encodeWWWForm という名前とすると、以下のように動作する。- encodeWWWForm([["k1","v1"], ["k2", "v2"]]) => "k1=v1&k2=v2"
- encodeWWWForm([["q","1+1=2"]]) => "q=1%2B1%3D2"
- encodeWWWForm([[" :/?#[]@!$&'()*+,;=-._~", " :/?#[]@!$&'()*+,;=-._~"]]) => "+:/?%23%5B%5D@!$%26'()*%2B,%3B%3D-._~=+:/?%23%5B%5D@!$%26'()*%2B,%3B%3D-._~"
この関数には以下の利点がある。
- ふたつ(以上)の文字列がひとつの文字列に合成されるので、未エスケープの文字列とエスケープ済みの文字列を取り違えにくい
- 結果の文字列に必ず "=" が含まれるので、値を調べるだけでだいたい合成済みだと分かる(未エスケープの文字列に "=" が含まれている場合を考えると、確実に区別できるわけではない)
- encodeURIComponent よりもエスケープが少なく人間が結果を読みやすい
同様に、分解とアンエスケープを同時に行う関数も考えられる。つまり、"K1=V1&K2=V2&..." という文字列を受け取り、[[k1, v1], [k2, v2], ...] を返すというものである。
この関数を仮に decodeWWWForm という名前とすると、以下のように動作する。- decodeWWWForm("k1=v1&k2=v2") => [["k1","v1"], ["k2", "v2"]]
- decodeWWWForm("q=1%2B1%3D2") => "q=1%2B1%3D2"
- decodeWWWForm("+:/?%23%5B%5D@!$%26'()*%2B,%3B%3D-._~=+:/?%23%5B%5D@!$%26'()*%2B,%3B%3D-._~") => [[" :/?#[]@!$&'()*+,;=-._~", " :/?#[]@!$&'()*+,;=-._~"]]
この関数にも同様の利点がある。
- ひとつの文字列がふたつ(以上)の文字列に分解されるので、エスケープ済みの文字列と未エスケープの文字列を取り違えにくい
- 結果の文字列には "=" が含まれないことが多いので、値を調べるだけでだいたい分解済みだと分かる(未エスケープの文字列に "=" が含まれている場合を考えると、確実に区別できるわけではない。)
-
HTMLの属性専用のエスケープ関数を作る
URIと同様にエスケープがセキュリティ問題になり得るケースとして、HTML の生成がある。HTMLの生成には URI とは異なるエスケープが必要である。たとえば、"&" という文字を表現したければ "&" や "&" といったものに変換しなければならない。そして、URI と同様に、エスケープが必要となる文字は HTML の各部分によって異なる。HTML の地の文 (#PCDATA) では、ダブルクォートをそのまま記述できるが、属性(たとえば <a href="http://example.org"> の "http://example.org" の部分)をダブルクォートで括って記述する中では、ダブルクォートは直接は記述できず?""" などとしなければならない。これは、ダブルクォートを直接記述すると属性の終端と混同して曖昧になってしまうからである。
また、属性を括るにはシングルクォートも使用できるが、この場合はシングルクォートを直接には記述できなくなる。ここで、Web アプリケーションがユーザ入力を元に HTML を生成する場合、エスケープを行う必要がある。エスケープを忘れると XSS(Cross Site Scripting)という脆弱性の原因となる。ここで、属性についてエスケープを忘れない方法として、URI の application/x-www-form-urlencoded と同様の方法が考えられる。つまり、文字列をエスケープした後にダブルクォートで括った文字列を返す関数を用意するのである。たとえば、"a&b" という3文字の長さの文字列をこの関数を使ってエスケープすると、"a&b" という7文字の長さの文字列ではなく、"\"a&b\"" という9文字の長さの文字列を生成する。
この関数には以下の利点がある。- エスケープ済みの文字列にはダブルクォートが付加されるので、文字列をみるとエスケープ済みかどうかだいたい分かる(未エスケープの文字列の最初と最後にダブルクォートがついている場合を考えると、確実に区別できるわけではない。)
- エスケープ済みの文字列を再度エスケープすると、ダブルクォートが """ に変化するので間違いに気がつく
- HTML の開始タグを生成するところで、属性を括るダブルクォートを記述しなくて済む
5. URI の変遷
URI は RFC で定義されるが、前述したように定義の内容は変化している。
この節では文字種の分類について、その変遷をまとめる。
まず、URI を定義する RFC は以下のように変遷している。
- RFC 1738 Uniform Resource Locators (URL)
- RFC 1808 Relative Uniform Resource Locators
- RFC 2396 Uniform Resource Identifiers (URI): Generic Syntax
- RFC 2732 Format for Literal IPv6 Addresses in URL's
- RFC 3986 Uniform Resource Identifier (URI): Generic Syntax
最初に RFC 1738 で絶対 URL が定義され、RFC 1808 で相対 URL が定義された。そして、RFC 2396 で URL と URN(Uniform Resource Name)を統合するものとして URI が定義された。URN は RFC 2141で定義されるが、文法的には URI のスキームのひとつとみなせるので、本稿では触れない。RFC 2732 では IPv6 のアドレスを記述する方法が追加された。そして、RFC 3986 が現時点(2010年1月)における最新版である。
以下の表は ASCII の表示文字それぞれについて、各 RFC がどの文字種に分類しているかを示したものである。文字の性質として重要なのはそれが unreserved に属するか、reserved に属するか、あるいはどちらでもないかである。unreserved と reserved といった文字種の定義はそれぞれの RFC で以下のようになっている。... という省略は文字のリストが記述されている部分である。
RFC3986: reserved = gen-delims / sub-delims gen-delims = ... sub-delims = ... unreserved = ALPHA / DIGIT / ... ALPHA = ... DIGIT = ... RFC2396: reserved = ... unreserved = alphanum / mark alphanum = alpha / digit alpha = lowalpha / upalpha mark = ... digit = ... lowalpha = ... upalpha = ... delims = ... ; reserved と unreserved には使われていない unwise = ... ; reserved と unreserved には使われていない RFC1738,1808: reserved = ... unreserved = alpha / digit / safe / extra alpha = lowalpha / hialpha digit = ... safe = ... extra = ... lowalpha = ... hialpha = ... national = ... ; reserved と unreserved には使われていない punctuation = ... ; reserved と unreserved には使われていない |
たとえば、RFC 3986 の gen-delims には ":" が含まれているので、":" は gen-delims に属し、gen-delims は reserved の一種である。これを表では gen-delims(r) と記述する。(r) は reserved の意味である。
また、同様に unreserved は (u) と記述する。
また、ASCII という文字コードは ISO 646 という国際規格の米国版である。ISO 646は 7bit コードの定義であり、一部のコードを各国で変えてよいという仕組みになっている。以下の表の「可変」はその変えてよい文字である。(本稿では、ISO 646 自体でなく、その日本版の規格である JIS X 0201 - 1997 を参考にした。)
また、英数字はそれぞれすべて扱いが同じなため、代表して "0", "A", "a" を記載する。
URI IPv6 URI URL ISO646 oct dec hex 文字 RFC 3986 RFC 2732 RFC 2396 RFC 1738,1808 041 33 21 ! sub-delims(r) mark(u) extra(u) 042 34 22 " delims punctuation 043 35 23 # gen-delims(r) delims punctuation 可変: NUMBER SIGN / POUND SIGN 044 36 24 $ sub-delims(r) reserved(r) reserved(r) safe(u) 可変: DOLLER SIGN / CURRENCY SIGN 045 37 25 % delims punctuation 046 38 26 & sub-delims(r) reserved(r) reserved(r) reserved(r) 047 39 27 ' sub-delims(r) mark(u) extra(u) 050 40 28 ( sub-delims(r) mark(u) extra(u) 051 41 29 ) sub-delims(r) mark(u) extra(u) 052 42 2A * sub-delims(r) mark(u) extra(u) 053 43 2B + sub-delims(r) reserved(r) reserved(r) safe(u) 054 44 2C , sub-delims(r) reserved(r) reserved(r) extra(u) 055 45 2D - unreserved(u) mark(u) safe(u) 056 46 2E . unreserved(u) mark(u) safe(u) 057 47 2F / gen-delims(r) reserved(r) reserved(r) reserved(r) 072 58 3A : gen-delims(r) reserved(r) reserved(r) reserved(r) 073 59 3B ; sub-delims(r) reserved(r) reserved(r) reserved(r) 074 60 3C < delims punctuation 075 61 3D = sub-delims(r) reserved(r) reserved(r) reserved(r) 076 62 3E > delims punctuation 077 63 3F ? gen-delims(r) reserved(r) reserved(r) reserved(r) 100 64 40 @ gen-delims(r) reserved(r) reserved(r) reserved(r) 可変 133 91 5B [ gen-delims(r) reserved(r) unwise national 可変 134 92 5C \ unwise unwise national 可変 135 93 5D ] gen-delims(r) reserved(r) unwise national 可変 136 94 5E ^ unwise unwise national 可変 137 95 5F _ unreserved(u) mark(u) safe(u) 140 96 60 ` unwise unwise national 可変 173 123 7B { unwise unwise national 可変 174 124 7C | unwise unwise national 可変 175 125 7D } unwise unwise national 可変 176 126 7E ~ unreserved(u) mark(u) national 可変 060 48 30 0 DIGIT(u) digit(u) digit(u) 101 65 41 A ALPHA(u) upalpha(u) hialpha(u) 141 97 61 a ALPHA(u) lowalpha(u) lowalpha(u) |
RFC 1738,1808 から RFC 2396 では、"$", "+", "," が unreserved から reserved へ変化し、"~" が national から unreserved に変化している。RFC 1738,1808 の national は reserved でも unreserved でもなく、使用できない文字である。
RFC 2396 から RFC 2732 では、"[", "]" が unwise から reserved へ変化している。RFC 2396 の unwise は reserved でも unreserved でもなく、使用できない文字である。ここで、"[", "]" は IPv6 アドレスを括るために許されたが、それ以外の部分では使えないままである。なお、RFC 2732 では IPv6 アドレスのために reserved と unwise を修正するという形で定義したため、RFC 2732 で記述されていない部分は RFC 2396 と同じである。
RFC 2732 から RFC 3986 では、"#" が delims から reserved へ変化し、"!", "'", "(", ")", "*" が unreserved から reserved へ変化している。ここで、"#" の変化は、RFC 2732 までは fragment の部分が URI の一部とはみなされていなかったのが、RFC 3986 で URI の一部とみなされるようになったことに伴う変化であり、実際には以前から "#" は fragment に前置する区切りとして使えていた。
URI で使われる文字は、reserved, unreserved およびパーセントエンコーディングの一部としての "%" である。それ以外の文字は使えない。使えない文字の理由として、RFC 1738 では、"<", ">", "\"", "#" は URI を自体を区切るのに使われるために禁止されると述べられている。また、"{", "}", "|", "\", "^", "~", "[", "]", "`" の 9 文字はゲートウェイなどによって変更されることがあるので禁止されると述べられている。これはこの 9 文字がすべて ISO 646 の各国版で変化する文字であることと、その 9 文字が national という名前で参照されていることから、ISO 646 の各国版の違いに起因すると考えられる。ただし、ISO 646 で変化可能な部分すべてが禁止されているわけではなく、"$" と "@" は RFC 1738 の時点で許されているし、その後 RFC 2396 で "~"、RFC 2732 で "[" と "]"、RFC 3986 で "#" が許されるように変化している。このように、URI は使える文字が増えていく傾向にある。
また、記号が unreserved から reserved へ変化することがある。RFC 2396 では、"$", "+", "," が unreserved から reserved へ変化している。RFC 2732 では、"[", "]" が unwise から reserved へ変化している。RFC 3986 では、"!", "'", "(", ")", "*" が unreserved から reserved へ変化している。このように、URI の定義では、reserved が増えていく傾向にある。
6. まとめ
本稿では、ECMAScript の関数を題材としてURIのエスケープについて論じた。
decodeURIComponent は問題なく使える関数である。encodeURIComponent は RFC の変化に追随していないため、用途によっては問題が生じうる。encodeURI と decodeURI は使わない方がよい。
一般に、エスケープの正しい動作は用途に依存する。このため、エスケープ関数は用途をはっきりと決めて設計しなければならない。そして、その用途は関数名やドキュメントでわかるようにすべきである。また、多重エスケープ防止のため、可能なら、未エスケープの状態とエスケープ済みの状態を値で区別できることが望ましい。
以上
参考文献
[RFC1738] | |
RFC 1738, " Uniform Resource Locators (URL)", |
|
[RFC1808] | |
RFC 1808, " Relative Uniform Resource Locators", R. Fielding, June 1995, http://www.ietf.org/rfc/rfc1808.txt |
|
[RFC2396] | |
RFC 2396, " Uniform Resource Identifiers (URI): Generic Syntax", T. Berners-Lee, R. Fielding, L. Masinter, August 1998, http://www.ietf.org/rfc/rfc2396.txt |
|
[RFC2732] | |
RFC 2732, "Format for Literal IPv6 Addresses in URL's", R. Hinden, B. Carpenter, L. Masinter, December 1999, http://www.ietf.org/rfc/rfc2732.txt |
|
[RFC3986] | |
RFC 3986, "Uniform Resource Identifier (URI): Generic Syntax", T. Berners-Lee, R. Fielding, L. Masinter, January 2005, http://www.ietf.org/rfc/rfc3986.txt |
|
[RFC2141] | |
RFC 2141,"URN Syntax", Moats, R., May 1997, http://www.ietf.org/rfc/rfc2141.txt |
|
[RFC2234] | |
RFC 2234, "Augmented BNF for Syntax Specifications: ABNF", D. Crocker, Ed., P. Overell, November 1997, http://www.ietf.org/rfc/rfc2234.txt |
|
[HTML4.01] | |
HTML 4.01 Specification, December 1999, http://www.w3.org/TR/1999/REC-html401-19991224/ |
|
[JISC] | |
JIS X3010:2003 プログラム言語 C | |
[JISX0201] | |
JIS X 0201 - 1997 7ビット及び8ビットの情報交換用符号化文字集合 | |
[ISO646] | |
ISO/IEC 646:1991 Information technology -- ISO 7-bit coded character set for information interchange | |
[ECMAScript] | |
Standard ECMA-262 ECMAScript Language Specification, 5th edition (December 2009), http://www.ecma-international.org/publications/standards/Ecma-262.htm |