Go os.Rootでパストラバーサルを防ぐ—filepath.Cleanで不十分な理由

Go os.Rootでパストラバーサルを防ぐ—filepath.Cleanで不十分な理由 | mohablog
目次

「uploads配下だけ」のはずが、外のファイルを読めた

ユーザーが指定したファイル名を受け取って開く。Webアップロードの取得やCLIのファイル処理でよくある形です。ここに、自分で書いた防御コードをすり抜ける穴が残ることがあります。

次のsafeJoinは、filepath.Joinした結果がベースディレクトリ配下かをstrings.HasPrefixで確認します。前方一致で弾くので、外には出られないように見える。

func safeJoin(base, userPath string) (string, error) {
	full := filepath.Join(base, userPath)
	absBase, _ := filepath.Abs(base)
	absFull, _ := filepath.Abs(full)
	if !strings.HasPrefix(absFull, absBase+string(os.PathSeparator)) {
		return "", fmt.Errorf("不正なパス: %s", userPath)
	}
	return absFull, nil
}

// uploads/link は ../outside_secret.txt を指すシンボリックリンク
p, err := safeJoin("uploads", "link")
if err == nil {
	b, _ := os.ReadFile(p)
	fmt.Printf("通過 -> 読めた中身: %q\n", string(b))
}

Go 1.26.4で実行した結果です。

通過 -> 読めた中身: "SECRET via symlink\n"

uploads/linkは確かにuploads配下にあります。ただ実体は親ディレクトリを指すシンボリックリンク。文字列の前方一致は、この差を見抜けません。Go 1.24で入ったos.Rootは、ここを言語側で塞ぎます。

os.Rootは「ディレクトリの外に出られない」を保証する

go doc os.Rootを引くと、型の役割がそのまま書いてあります。

Root may be used to only access files within a single directory tree. Methods on Root can only access files and directories beneath a root directory. If any component of a file name passed to a method of Root references a location outside the root, the method returns an error.

ファイル名のどの成分が外を指してもエラー。この「成分ごとに見る」点が、文字列の前方一致との違いです。

os.OpenRootで開く

os.OpenRoot(dir)でディレクトリを開き、*os.Rootを受け取ります。あとはosパッケージと同じ感覚で、root.Openroot.Createを呼ぶだけ。引数のパスはすべてrootからの相対です。

root, err := os.OpenRoot("data")
if err != nil {
	panic(err)
}
defer root.Close()

// root内のファイルは普通に開ける
if f, err := root.Open("public.txt"); err == nil {
	fmt.Println(`root.Open("public.txt") -> 成功`)
	f.Close()
}

// 相対で外へ出ようとすると弾かれる
_, err = root.Open("../secret.txt")
fmt.Printf(`root.Open("../secret.txt") -> err: %v`+"\n", err)

// 絶対パスも同様
_, err = root.Open("/etc/hosts")
fmt.Printf(`root.Open("/etc/hosts") -> err: %v`+"\n", err)
root.Open("public.txt") -> 成功
root.Open("../secret.txt") -> err: openat ../secret.txt: path escapes from parent
root.Open("/etc/hosts") -> err: openat /etc/hosts: path escapes from parent

rootの外を指すパスはエラーになる

エラーメッセージはpath escapes from parent..でも絶対パスでも同じ理由で止まります。シンボリックリンクの扱いはドキュメントが明示しています。

Methods on Root will follow symbolic links, but symbolic links may not reference a location outside the root. Symbolic links must not be absolute.

リンク自体はたどる。ただし飛び先が外なら拒否、絶対パスのリンクも拒否。冒頭のsafeJoinが見落とした穴を、ここで閉じています。

errors.Isで404扱いしない

1つ気をつけたいのが、この escape エラーはos.ErrNotExistではない点。「ファイルが無い」と区別せずに握りつぶすと、攻撃の痕跡を404として流してしまいます。

_, err = root.Open("../secret.txt")
fmt.Printf("errors.Is(err, os.ErrNotExist)? %v\n", errors.Is(err, os.ErrNotExist))
errors.Is(err, os.ErrNotExist)? false

filepath.CleanとHasPrefixが漏らす理由

自前チェックが破れるのは、文字列を見ているだけで実体を解決していないから。uploads/linkという文字列はuploadsで始まるので前方一致を通る。リンクの先がどこかは、文字列からは分かりません。

過去に似たsafeJoinを書いてレビューを通したことがありますが、symlinkを置いたテストが無く、漏れに気づけませんでした。同じ入力をos.Rootに渡すと結果が変わります。

root, _ := os.OpenRoot("uploads")
defer root.Close()
data, err := root.ReadFile("link") // 中身は ../outside_secret.txt
if err != nil {
	fmt.Printf("ブロック -> err: %v\n", err)
}
ブロック -> err: openat link: path escapes from parent

「リンクを評価してから前方一致」という手もあります。ただ評価と利用の間にリンクを差し替えられると破れる。いわゆるTOCTOUです。3方式を並べると差がはっきりします。

方式..によるescapesymlinkによるescape弱点
filepath.Clean + 前方一致防げる漏れる実体を解決しない
EvalSymlinks + 前方一致防げる防げるチェック後の差し替えに弱い(TOCTOU)
os.Root防げる防げる後述の対象外あり

Go 1.25で実務に届いた

Go 1.24時点のos.RootOpenCreateMkdirStatなど最小限でした。読み書きを毎回OpenFileから組むのは手間。Go 1.25で高水準メソッドがまとめて入り、osパッケージとほぼ同じ顔ぶれになりました。

バージョンリリースos.Root関連の動き
Go 1.242025年2月os.OpenRoot / Root型を追加。Open・Create・Mkdir・Stat・Lstat・Removeなど
Go 1.252025年8月ReadFileWriteFileMkdirAllRemoveAllRenameSymlinkChmodなどを追加。Root.FSio/fs.ReadLinkFSを実装
Go 1.262026年2月現行の安定版は1.26.4(2026年6月)

アップロード受け口に組み込む

実務では「保存先ディレクトリを開いて、その中だけで読み書きする」形になります。MkdirAllでサブディレクトリを掘り、WriteFileで保存、FS()で一覧。すべてroot境界の内側に閉じます。

root, _ := os.OpenRoot("workspace")
defer root.Close()

root.MkdirAll("logs/2026", 0o755)
root.WriteFile("logs/2026/app.log", []byte("started\n"), 0o644)

// 外への書き込みは弾かれる
err := root.WriteFile("../escape.log", []byte("x"), 0o644)
fmt.Printf(`WriteFile("../escape.log") -> err: %v`+"\n", err)

// Root.FS() は io/fs.ReadLinkFS を実装。WalkDir でそのまま歩ける
fsys := root.FS()
_, ok := fsys.(fs.ReadLinkFS)
fmt.Printf("fs.ReadLinkFS 実装? %v\n", ok)
fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
	fmt.Println(" ", p)
	return nil
})
WriteFile("../escape.log") -> err: openat ../escape.log: path escapes from parent
fs.ReadLinkFS 実装? true
  .
  logs
  logs/2026
  logs/2026/app.log

Root.FS()が返すfs.FSio/fs.ReadLinkFSを満たします。テンプレート配信や静的ファイル配信でfs.FSを受け取る既存コードに、境界付きのまま差し込めます。

os.Rootが守らないもの

守る範囲には線が引かれています。ドキュメントは対象外をはっきり書いています。

Methods on Root do not prohibit traversal of filesystem boundaries, Linux bind mounts, /proc special files, or access to Unix device files.

つまり、root内に/procへのbind mountやデバイスファイルが存在すれば、そこには届きます。守るのは「パスがツリーの外へ出ること」であって、ツリー内に持ち込まれた危険物ではありません。

プラットフォーム差もあります。go docが挙げている主な注意点はこのあたり。

  • Unix: ChmodChownChtimesは競合に弱い。操作中に対象が通常ファイルからシンボリックリンクへ差し替わると、リンク先ではなくリンク側に操作が及ぶ場合があります
  • GOOS=js: symlink検証でTOCTOUに弱く、root外への脱出を防ぎきれません
  • GOOS=windows: NULCOM1などの予約デバイス名は参照できません
  • GOOS=plan9 / js: rename をまたいだディレクトリ追跡をしません

Linuxサーバーで素直に使うぶんには、最後の3つはほぼ気にしなくて済みます。気をつけるのは競合するChmod系と、root内に何を置くか。

まとめ

os.Rootはパストラバーサル対策を、自前の文字列チェックから言語側へ移す仕組みです。

  • os.OpenRootで開けば、各操作がroot境界の内側に閉じる。..・絶対パス・外を指すsymlinkはすべてpath escapes from parentで弾かれる
  • filepath.Clean+前方一致はsymlinkを見抜けない。EvalSymlinksを足してもTOCTOUが残る
  • escapeエラーはos.ErrNotExistではない。404と混ぜて握りつぶさない
  • Go 1.25でReadFileWriteFileMkdirAllなどが揃い、Root.FS()io/fs.ReadLinkFSを実装
  • 守るのはツリー外への脱出のみ。bind mountや/proc、デバイスファイル、UnixのChmod系の競合は対象外

ユーザー入力でファイルを触るコードにsafeJoinのような関数を書いているなら、Go 1.24以降はos.OpenRootに置き換えるだけで穴が1つ減ります。現行のGo 1.26.4ならメソッドも一通り揃っています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次