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