URLやURIを扱う実装では、入力された文字列をもとに「このURLを許可してよいか」「このpathは想定範囲内か」「このhostへ接続してよいか」を判定する場面があります。

ただ、URIは単なる文字列ではありません。構文があり、percent-encodingがあり、parserやnormalizerの差もあります。そのため、検証時に見えていた文字列と、実際にアクセス、保存、解決されるときの意味がずれることがあります。

この記事では、そのずれを避けるために、実装前にどの文字や表現を確認しておくべきかを整理します。

参考にしたもの

URIとは何か

URIは Uniform Resource Identifier の略で、リソースを識別するための文字列です。代表的には次のようなものがあります。

https://example.com/docs/page
mailto:user@example.com
urn:isbn:9781234567890
file:///home/user/test.txt

普段よく使うURLも、URIの一種として扱えます。

多くのURIは、次のような構造を持ちます。

scheme://userinfo@host:port/path?query#fragment

ここで大事なのは、URI中の文字には「データとしての文字」と「構造を区切る文字」があることです。

例えば / はpathの区切り、? はqueryの開始、# はfragmentの開始、@ はuserinfoとhostの境界として使われます。

そのため、URIを安全に扱うには、その文字が含まれているかだけではなく、どの構文要素の中で解釈されるかを見る必要があります。

RFC 3986とpercent-encoding

RFC 3986は、URIの共通構文を定義する仕様です。URIの構造、使える文字、percent-encoding、reserved / unreserved character、相対参照、dot-segment removal、正規化と比較などが定義されています。

percent-encodingは、URI中の文字やバイト列を % と16進数2桁で表す仕組みです。

%20 -> space
%2e -> .
%2f -> /
%3f -> ?
%23 -> #
%40 -> @
%25 -> %

percent-encodingは暗号化ではありません。同じ文字を、URI上で別の表現にしているだけです。

reservedとunreserved

RFC 3986では、URIで使われる文字を大きく reserved character と unreserved character に分けています。

unreserved character は、URIの構造を区切るためには使われない文字です。

A-Z
a-z
0-9
-
.
_
~

これらはpercent-encodingされていても、正規化によって元の文字に戻されることがあります。

%7e -> ~
%41 -> A
%2e -> .

一方、reserved character はURIの構造を区切るために使われる文字です。

: / ? # [ ] @
! $ & ' ( ) * + , ; =

例えば / はpath segmentの区切り、? はqueryの開始、# はfragmentの開始、@ はuserinfoとhostの境界です。

そのため、reserved character をどのタイミングでdecodeするかによって、URIの構造が変わることがあります。

問題は「decodeできること」ではない

percent-encodingされた文字があること自体は問題ではありません。

問題は、検証時と使用時で解釈が変わることです。

検証時:
  生の文字列を見る

使用時:
  decode・正規化・再解析された結果を使う

ある処理では %2f を普通の文字として扱い、別の処理では / として扱う、という差分もあります。

このような解釈差分があると、文字列としては許可範囲内に見える入力が、実際には別の場所や別の構造として扱われる可能性があります。

実装前に確認する表現

ここから、確認しておきたい表現を整理します。

基準は単純です。

decodeや正規化によって、URIや後続処理の構造を変え得るものを確認する

まず、URI構造に関係する文字です。

encode表現decode後観点
%2e.dot segment
%2f/path separator
%5c\Windows path separator
%25%double encoding
%3f?query開始
%23#fragment開始
%40@userinfo / host境界
%3a:scheme / port / drive letter
%26&query parameter区切り
%3d=query key/value区切り
%2b+form encodingとの関係

これらはdecodeされたときに、URIや後続処理の構造を変え得るため、実装前に確認対象へ入れておきたいです。

pathで注意する表現

pathでは、.../\ が重要です。

.   current
..  parent
/   path separator
\   Windows path separator

そのため、次のような表現をテストケースに入れておくと役に立ちます。

%2e
%2e%2e
.%2e
%2e.
%2e%2e/
%2e%2e%2f
..%2f
%2e%2e%5c
..%5c

これらは、decodeや正規化によって次のような形になり得ます。

%2e%2e%2f -> ../
%2e%2e%5c -> ..\

pathの境界を判定する実装では、これらを事前に試しておくべきだと思います。

double encoding

%25% を表します。

そのため、decodeが複数回行われると意味が変わる入力があります。

%252e

これは1回decodeすると次のようになります。

%2e

さらにもう1回decodeすると、次のようになります。

.

同じように、次の入力は2回decodeされると ../ になります。

%252e%252e%252f

double encodingを考える場合は、次の表現も確認対象になります。

%252e
%252e%252e
%252f
%255c
%252e%252e%252f
%252e%252e%255c

ここで見たいのは「この文字列が危険か」そのものではなく、どこかで二重decodeが起きたときに別の意味へ変わるかです。

hostやauthorityで注意する表現

hostやoriginを判定する場合は、pathとは別の観点が必要です。

特に @#?: は注意対象です。

@  userinfoとhostの境界
#  fragmentの開始
?  queryの開始
:  schemeやportの区切り

例えば、次のURLでは trusted.example が含まれていますが、実際のhostは evil.example です。

https://trusted.example@evil.example/

@ より前はuserinfoだからです。

そのため、hostやoriginを検証するときは、文字列検索ではなく、URI parserで分解した後の schemehostport を見る必要があります。

queryやformで注意する表現

queryやformでは、pathとは別の規則が関係します。

特に application/x-www-form-urlencoded では、+ がspaceとして扱われます。

+    -> space
%20  -> space
%2b  -> +

また、query parameterでは &= が区切りとして使われます。

%26 -> &
%3d -> =

そのため、query parameter内でURL、path、redirect先、権限情報などを扱う場合は、query/form parserの規則も確認する必要があります。

実装前の確認ポイントをどう使うか

ここまでの話は、単に危険文字一覧を覚えるためのものではありません。

実装では、次の順で確認するとずれを見つけやすいです。

  1. どの段階でdecodeされるかを整理する
  2. どの段階で正規化や再解析が行われるかを見る
  3. 検証が生文字列に対して行われていないかを確認する
  4. 実際に使うparserやframeworkで同じ入力を通して差を見る
  5. path、host、queryを別々にテストする

特に重要なのは、validate before canonicalize の順序ミスを避けることです。見た目では安全に見える入力でも、decodeやcanonicalizeのあとに別の意味へ変わるなら、検証としては足りません。

まとめ

今回整理したものは、単なる危険文字一覧ではありません。

URIの構文、percent-encoding、reserved / unreserved character、dot-segment、double decoding、query/form、ファイルシステムを順番に見て、実装のどこで意味が変わり得るかを洗い出したものです。

最小限の確認対象としては、まず次のあたりから始めるとよいと思います。

%2e   .
%2f   /
%5c   \
%25   %
%3f   ?
%23   #
%40   @
%3a   :
%26   &
%3d   =
+      space in form/query

URIやURLの入力検証では、危険なのは文字そのものではなく、検証時と利用時で意味が変わることです。そこを先に意識しておくと、実装後に気づく解釈差分をかなり減らせるはずです。