filepath.Joinの安全な使い方 - ディレクトリトラバーサルに注意しよう
Goでファイルパスを結合する際によく使われるfilepath.Joinは便利ですが、ユーザー入力をそのまま扱うとディレクトリトラバーサルの脆弱性につながることがあります。この記事では、まず最初にfilepath.Joinの動作について見ていき、どの仕様が脆弱性につながるのか、どうすれば防げるのかについて説明します。
filepath.Joinの基本動作
まずfilepath.Joinの基本的な動作についてですが、引数で渡した文字列をOSのパスの仕様に基づいて結合します。Unix系では以下のように/をパスの区切り文字として結合されます。
joined := filepath.Join("/foo", "bar", "baz")
fmt.Println(joined) // => /foo/bar/baz
ただし、.. 等が含まれると、ただ結合するだけでなく簡略化が行われます。
joined := filepath.Join("/foo", "bar", "..", "baz")
fmt.Println(joined) // => /foo/baz
filepath.Joinの実装とGodocは以下の通りです。The result is Cleanedとありますが、これが先ほどの簡略化を指しています。
呼び出されているjoinはOSで実装が分かれており、Unix系OS[1]での実装はシンプルですが、Windowsはパスの仕様がやや複雑なので実装もあわせて複雑になっています
これらの実装からわかるようにThe result is CleanedのCleanはfilepath.Cleanのことです。次に、この関数の処理についてみていきます。
filepath.Cleanとは
filepath.Clean関数は、パスに含まれる冗長な要素を取り除き、同じ意味を持つ最も短いパス表現に変換します。この処理は実際のファイルシステムを参照せず、純粋に文字列操作だけで行われます。
具体的には、パスを先頭から順番に処理しながら以下のルールを適用し、簡略化されたパスを構築します(例ではパスの区切り文字として/を用いています)。
- 連続した複数の区切り文字を1つにまとめます。
- 例:
/foo//bar→/foo/bar
- 例:
- カレントディレクトリを示す
.を取り除きます。- 例:
/foo/./bar→/foo/bar
- 例:
- 親ディレクトリを示す
..を解決します。- 具体的には、
..が現れた場合、直前にあるディレクトリ要素を1つ削除します。- 例 (絶対パス):
/foo/bar/../baz→/foo/baz - 例 (相対パス):
foo/bar/../../baz→baz
- 例 (絶対パス):
- 削除可能な要素がない場合、絶対パスではルートより上には行けないため
..は無視(削除)し、相対パスでは..をそのまま残します。- 例 (絶対パス):
/../foo→/foo - 例 (相対パス):
../foo→../foo
- 例 (絶対パス):
- 具体的には、
これらの処理をすべて行なった結果が空文字になった場合は、カレントディレクトリを示す.を返します。
なお、上記以外にもWindowsの場合にのみ意味がある処理[2]がありますが、そちらについてはこの後の話と関連が薄いため省略しています。
filepath.Joinの安全な使い方
ようやくの本題ですが、filepath.Joinのよくあるユースケースとして、最初の引数は定数や起動時に指定するディレクトリで、これにユーザからの入力を結合するというものがあります。これを単純に以下のように書くと何が起きるでしょうか?
joined := filepath.Join("/var/html/www", inputFromUser)
上記のような使い方をするときは、結合結果が第一引数の"/var/html/www"配下のパスになることを想定していることが多いと思いますが、この書き方だとinputFromUserに"/../../../etc/passwd"や../../../etc/passwdのような入力がくると、"/etc/passwd"となり、結果として任意のパスを指定できてしまいます。いわゆるディレクトリトラバーサル攻撃です。
joined := filepath.Join("/var/html/www", "/../../../etc/passwd")
fmt.Println(joined) => "/etc/passwd"
joined = filepath.Join("/var/html/www", "../../../etc/passwd")
fmt.Println(joined) => "/etc/passwd"
これを防ぐためには、以下のようにユーザからの入力を"/"とfilepath.Joinしたのちに、目的のパスとfilepath.Joinします。
cleanedPath := filepath.Join("/", inputFromUser)
joined := filepath.Join("/var/html/www", cleanedPath)
なぜこれでうまくいくのかについてですが、まず、正常系については、"/"と入力文字列が結合することで//がパスの先頭に現れる可能性がありますが、これは結合後に内部的に呼ばれているfilepath.Cleanによって/になります(前述したfilepath.Cleanの変換ルール1を参照)。そのため、最終的な結果に違いは生まれません。
一方で、不正な文字列についてですが、"/"とのfilepath.Joinによって、絶対パスに変換された後にfilepath.Cleanの処理が呼ばれるため、filepath.Cleanの変換ルール3に基づき..がパスから消えます。これによってディレクトリトラバーサル攻撃を防ぐことができます。
絶対パスに変換された後にfilepath.Cleanの処理が呼び出されるところがポイントで、単に filepath.Clean("/", inputFromUser) にしてしまうと、相対パスの先頭の..は消えないため、../../../etc/passwdのような文字列による攻撃は防げないので注意が必要です。
それでも防げないこと
これまで説明してきた方法で防げるのは、あくまで文字列レベルでの攻撃です。シンボリックリンクなどを使って、想定外のファイルにアクセスできる可能性は残るため、実際のファイルシステムにおけるパスの検証も併せて検討してください。
-
Goのビルド分岐(
//go:build unix)でのUnix系OS判定は cmd/dist/build.go で定義されています ↩︎ -
filepath.Cleanの実態はinternalにある filepathlite.Clean ですが、ボリューム名の考慮等が入っています。 ↩︎
Discussion