URLやURIを扱う実装では、入力された文字列をもとに「このURLを許可してよいか」「このpathは想定範囲内か」「このhostへ接続してよいか」を判定する場面があります。
ただ、URIは単なる文字列ではありません。構文があり、percent-encodingがあり、parserやnormalizerの差もあります。そのため、検証時に見えていた文字列と、実際にアクセス、保存、解決されるときの意味がずれることがあります。
この記事では、そのずれを避けるために、実装前にどの文字や表現を確認しておくべきかを整理します。
参考にしたもの
-
RFC 3986: Uniform Resource Identifier (URI): Generic Syntax URIの共通構文、reserved / unreserved character、percent-encoding、相対参照、正規化と比較などを定義する基本仕様です。
-
WHATWG URL Standard WebブラウザやWeb API寄りのURL解釈を定義する仕様です。RFC 3986と完全に同じではないため、実装系ごとの差を見る出発点になります。
-
OWASP Path Traversal path traversalの代表的なパターンと、encodingを使った回避例を整理した解説です。
-
CWE-174: Double Decoding of the Same Data 同じデータを複数回decodeしてしまうことで、意味が変わる問題をまとめたCWEです。
-
CWE-180: Incorrect Behavior Order: Validate Before Canonicalize canonicalizeやnormalizeの前後で検証の意味が変わる問題を整理したCWEです。
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で分解した後の scheme、host、port を見る必要があります。
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の規則も確認する必要があります。
実装前の確認ポイントをどう使うか
ここまでの話は、単に危険文字一覧を覚えるためのものではありません。
実装では、次の順で確認するとずれを見つけやすいです。
- どの段階でdecodeされるかを整理する
- どの段階で正規化や再解析が行われるかを見る
- 検証が生文字列に対して行われていないかを確認する
- 実際に使うparserやframeworkで同じ入力を通して差を見る
- 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の入力検証では、危険なのは文字そのものではなく、検証時と利用時で意味が変わることです。そこを先に意識しておくと、実装後に気づく解釈差分をかなり減らせるはずです。