説明
ユーザー入力から作ったファイルパスが想定したディレクトリを外れると、意図しない場所のファイルを読み書きされるおそれがあります。../ などによる親への移動や、リンクを使う迂回が代表例です。アーカイブの展開でも同じ問題が起こります。
想定される影響
- 設定、ソースコード、認証情報などの機密情報が漏えいする可能性があります。
- アップロードや展開でファイルが上書きされ、データ破損やサービス停止につながるおそれがあります。
- 実行に使うファイルを変更できる場合、コード実行へつながる可能性があります。
対処方法
- 固定したルートの内部だけでファイルを開いてください。
filepath.Cleanや文字列のプレフィックス検証だけでは、実際のファイルアクセスの境界を保証できません。 - ファイル名だけが必要なら単一の構成要素に限定し、相対パスが必要なら
filepath.Relで親への脱出を拒否してください。 - シンボリックリンクと検証後のパスの置き換えに対応した、ディレクトリを基準とする開き方を使ってください。Go 1.24以降では
os.OpenRootを検討できます。EvalSymlinksの後にパスを開き直す方法には競合が残ります。 - ファイルごとの権限、必要な拡張子・サイズ・深さの制限を適用し、検証失敗時には中止してください。
例
変更後は、文字列としてパスの範囲を検証する例です。/var/app/data は信頼できる管理者が管理し、外部を指すリンクや攻撃者が変更できるパスが存在しないことが前提です。保証できなければ、リンクによる脱出を防ぐ開き方へ変更してください。認証とファイルごとの権限検証は省略しています。
変更前
go
package main
import (
"net/http"
"os"
"path/filepath"
"github.com/gin-gonic/gin"
)
func download(c *gin.Context) {
// 変更前: ユーザー入力をパスへ直接使用
// 例: ?file=../../etc/passwd
file := c.Query("file")
baseDir := "/var/app/data"
// Joinは正規化するが、親への脱出を防ぐわけではない
fullPath := filepath.Join(baseDir, file)
if _, err := os.Stat(fullPath); err != nil {
c.String(http.StatusNotFound, "not found")
return
}
http.ServeFile(c.Writer, c.Request, fullPath)
}
func main() {
r := gin.Default()
r.GET("/download", download)
r.Run()
}
変更後
go
package main
import (
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/gin-gonic/gin"
)
func isUnderBase(base, target string) bool {
// パスを正規化し、区切り文字も含めてbaseの範囲を比較
b := filepath.Clean(base)
t := filepath.Clean(target)
sep := string(os.PathSeparator)
if runtime.GOOS == "windows" {
b = strings.ToLower(b)
t = strings.ToLower(t)
}
return strings.HasPrefix(t+sep, b+sep)
}
func safeDownload(c *gin.Context) {
file := c.Query("file")
baseDir := "/var/app/data" // 固定のルートディレクトリ
// 1) 結合したパスを文字列上でbaseDir内に制限
target := filepath.Join(baseDir, file)
if !isUnderBase(baseDir, target) {
c.String(http.StatusBadRequest, "invalid path")
return
}
// 2) 必要に応じた拡張子の許可リスト
// if !strings.HasSuffix(strings.ToLower(target), ".txt") { ... }
// 3) ファイルを提供
if _, err := os.Stat(target); err != nil {
c.String(http.StatusNotFound, "not found")
return
}
http.ServeFile(c.Writer, c.Request, target)
}
func main() {
r := gin.Default()
r.GET("/download", safeDownload)
r.Run()
}
解説:
- 変更前:
filepath.Joinはパスを正規化しますが、ルートの外に出る結果までは拒否しません。クエリから作ったファイルパスをServeFileへ渡すため、リクエストURLのパスに対する検証だけでは保護できません。 - 変更後: 区切り文字を含めたプレフィックス比較で、文字列上の境界を超えるパスを拒否します。シンボリックリンク、検証後の置き換え、ファイルごとの権限、ディレクトリ内容の公開まで解決する例ではありません。