Skip to content
Go
path/filepath — 路径运算与遍历安全

path/filepath — 路径运算与遍历安全

path/filepath 负责"路径字符串的代数":拼接、规范化、取扩展名、算相对路径、按模式匹配、遍历目录树。它按 GOOS 决定分隔符和绝对路径的形状(Windows 用 \ 与盘符,Unix 用 /)。path 是同构的包,只是永远按 / 处理——URL、HTTP 路由、归档内的路径用 path,真实文件系统用 path/filepath,这条边界一旦混用就会出跨平台 bug。

这一章的重点不是函数清单(go doc path/filepath 更齐全),而是两件在真实代码里反复出事的事:“这个路径还在我允许的目录里吗”,以及 WalkDir 里那些返回值的正确用法。除特别说明,本文的布尔值与错误文本都是 go1.27.0 / Windows 11 实测。


1. 核心函数速查

filepath.Join("a", "b", "c")        // 拼接并 Clean:一次搞定分隔符
filepath.Clean("a/./b/../c")        // "c"(Windows 上同为 "c")
filepath.Base("a/b/c.go")           // "c.go"
filepath.Dir("a/b/c.go")            // "a\b"
filepath.Ext("a.b.c")               // ".c"
filepath.Split("a/b/c.txt")         // dir="a/b/", file="c.txt"(dir 保留尾分隔符)
filepath.IsAbs("/a")                // Windows: true;Unix: true
filepath.Abs("x/y")                 // 以当前工作目录为基准
filepath.Rel("a/b/c", "a/b/d/e")    // Windows: "..\d\e"
filepath.VolumeName(`C:\a\b`)       // `C:`
filepath.FromSlash("a/b")           // 斜杠 -> 平台分隔符
filepath.ToSlash(`a\b`)             // 平台分隔符 -> 斜杠
filepath.Match("*.go", "main.go")   // true
filepath.Glob("data/*.csv")         // 匹配的文件名列表
filepath.WalkDir(root, fn)          // 遍历目录树
filepath.EvalSymlinks(p)            // 解析路径上的符号链接
filepath.SameFile(fi1, fi2)         // 是否同一文件(比字符串比较可靠)
filepath.SplitList(pathEnv)         // 按 ListSeparator 切开 PATH

几条实测边界,写判空逻辑时最容易撞上:

filepath.Base("")        // "."     ← 不是空串
filepath.Base("/")       // `\`     (Windows 上是根,Unix 上是 "/")
filepath.Base("a/")      // "a"
filepath.Dir("a")        // "."
filepath.Ext(".hidden")  // ".hidden" ← 点开头没有 basename,整个当扩展名
filepath.Ext("a.")       // "."
filepath.Clean("")       // "."
filepath.Clean("a//b")   // `a\b`
filepath.Join("a", "/b", "c") // `a\b\c`:Join 会剥掉元素的多余分隔符再拼
filepath.Join("a", "")   // "a"
filepath.Join("a", "..", "b") // "b" ← Join 隐含 Clean,会做词法抵消

陷阱:filepath.Join 里藏了一次 Clean,所以 Join(base, userInput) 会静默抵消 ../,路径看起来"还在 base 下面",实际指向了别处。这正是 HasPrefix 校验被绕过的机制之一(下一节展开)。想保留原样拼接的冲动,几乎总是错的。

Rel 只保证词法关系,两个参数都应是 Clean 过的路径;不同根之间它直接报错——实测 filepath.Rel("C:\\a", "D:\\b") 返回 Rel: can't make D:\b relative to C:\a。跨盘符/跨 UNC 主机的相对路径不存在,别去模拟它。


2. 路径遍历防护:filepath.IsLocal 与 os.Root

2.1 旧写法的缺口

手册早期版本给的是这个函数:

// 旧写法:不要再用
func safeReadFile(baseDir, userPath string) ([]byte, error) {
    fullPath := filepath.Join(baseDir, filepath.Clean(userPath))
    if !strings.HasPrefix(fullPath, filepath.Clean(baseDir)) {
        return nil, fmt.Errorf("invalid path: %s", userPath)
    }
    return os.ReadFile(fullPath)
}

它看起来严密,实际有两个洞:

  1. 前缀比较把兄弟目录也算"在里面"。baseDir 是 /data/uploads,而 /data/uploads-secret/x.txt、/data/uploadsX/a.txt 都以 /data/uploads 开头。攻击者控制的字符串只要落在这些兄弟目录里,就能通过检查。
  2. 归一化不对等。比较用的 filepath.Clean(baseDir) 与 Join 结果的分隔符、大小写(Windows 文件系统大小写不敏感)、短文件名(PROGRA~1)形式未必一致。实测还有一条更直接的:Windows 上 filepath.Join("/data/uploads", "/data/uploadsX/ok.txt") 得到 \data\uploads\data\uploadsX\ok.txt——因为 Windows 不认 /data/... 这种绝对形状,把它当普通片段拼了进去。也就是说,“用 Unix 风格字符串做词法推理"这件事本身就不跨平台。

正确做法靠两个标准件:判断用 filepath.IsLocal(Go 1.20 加入,GOROOT api/go1.20.txt 可查),落地用 os.Root(Go 1.24 才加入)。

2.2 filepath.IsLocal:词法上的"你出不了这个目录”

func safeReadFile(baseDir, userPath string) ([]byte, error) {
    if !filepath.IsLocal(userPath) { // 纯词法判断,快且可靠
        return nil, fmt.Errorf("not a local path: %q", userPath)
    }
    return os.ReadFile(filepath.Join(baseDir, userPath))
}

文档给的契约很硬:IsLocal(p) 为真 ⇒ Join(base, p) 一定落在 base 内,且 Clean(p) 是无根、不含 .. 的路径。于是"路径还在允许范围内"这句话不再需要你自己实现。

实测取值(go1.27.0 / Windows):

"a.txt" true   "a/b.txt" true   `a\b.txt` true   "sub/../ok.txt" true
"a/" true      "a//b" true      "./x" true       "x" true        "." true
".." false     "../x" false     "" false         "/" false       `\x` false
"C:\x" false   "/etc/passwd" false   "a/../../b" false
"NUL" false    "com1" false     "con" false      "nul.txt" true  "com1.txt" true
`\\?\C:\x` false  `\\.\COM1` false  `\\srv\share\x` false

三条要点:. 是合法的局部路径(它指向目录本身,Join 后就是 base);Windows 保留名会被拒绝,这是 HasPrefix 那套写法完全没有的防护;IsLocal 不做文件系统查询,不跟随符号链接,也不看路径是否存在,所以它是"输入校验"而不是"访问控制"——真正要挡住符号链接逃逸得上 os.Root。

2.3 os.Root:带边界的文件访问对象

root, err := os.OpenRoot(baseDir) // 打开一次,后续所有操作都被限制在这棵子树
if err != nil {
    return err
}
defer root.Close()

data, err := root.ReadFile("sub/in.txt")
f, err := root.Create("out.json")
sub, err := root.OpenRoot("sub") // 继续收窄作用域
entries, err := root.ReadDir(".")
err = fs.WalkDir(root.FS(), ".", func(p string, d fs.DirEntry, err error) error { ... })

实测越界行为(root 之外确实存在 out.bin):

_, err := root.ReadFile("../out.bin")
// openat ../out.bin: path escapes from parent
// 结构:&fs.PathError{Op:"openat", Path:"../out.bin", Err: <"path escapes from parent">}
// errors.Is(err, os.ErrInvalid) = false,errors.Is(err, os.ErrNotExist) 也是 false

所以这个错误只能按"非 nil 就拒绝"处理,不能用常见哨兵错误分类——这也是它与 fs.FS 契约的差异:os.DirFS(dir) 读到 .. 时报的是 readfile ../out.bin: invalid argument,errors.Is(err, os.ErrInvalid) 为 true(因为 fs.ValidPath 先拒绝了非法名字)。

顺带一条与上传处理直接相关的实测:把 "\x00" 传给 root.ReadFile 得到 openat <NUL>: invalid argument,这一条倒是 os.ErrInvalid(filepath.IsLocal("\x00") 为 true,词法检查放行的怪名字会在系统调用层被拦住)。文档还明确:Root 的方法可多 goroutine 并发使用;它会跟随符号链接,但链接指向根外或链接本身是绝对路径时会被拒。


3. 遍历目录树

filepath.WalkDir 是 Go 1.16 起的默认选择(比老的 Walk 便宜:fs.DirEntry 不预先取 Info)。

err := filepath.WalkDir(".", func(p string, d fs.DirEntry, err error) error {
    if err != nil {
        // 无法 Stat/ReadDir 的节点:d 可能是 nil,先判 err 再用 d
        return err // 中止整棵树
        // 或 return nil  // 忽略这一个节点,继续遍历
    }
    if d.IsDir() {
        switch d.Name() {
        case ".git", "node_modules":
            return filepath.SkipDir // 跳过这个目录的剩余内容
        }
        return nil
    }
    if filepath.Ext(p) == ".sum" {
        return nil
    }
    info, err := d.Info()
    if err != nil {
        return err
    }
    fmt.Println(p, info.Size())
    return nil
})

几个必须记住的规则:

  • WalkDir 不跟随符号链接:树里的符号链接目录只作为一个条目出现,不会递归进去(文档原文:WalkDir does not follow symbolic links)。所以"遍历 + 读取内容"的组合在符号链接面前不会失控,但也不等于你扫到了全部数据。
  • SkipDir 用在目录上跳过该目录剩余内容,用在文件上跳过所在目录的剩余文件(两个方向都能用,别只当目录专用)。filepath.SkipAll 则直接结束遍历。
  • 回调里的 err != nil 时 d 可能为 nil:先判错再用 d,否则会 panic。这是"遍历别人的目录就崩"的头号原因(权限不足、文件在遍历期间被删)。
  • 传给回调的 p 是从 root 参数拼出来的相对/绝对路径(root 是 "." 就是 ./x/y 形状),拿去和别的来源比较前记得 Clean。
  • 需要 os.Root 的安全边界时,遍历用 fs.WalkDir(root.FS(), ".", ...):p 变成合法 fs 路径,非法名字在源头就被挡住。

4. Glob 与 Match:** 不存在

m, err := filepath.Match("*.go", "main.go")   // true, nil
m, err = filepath.Match("a**c", "abc")        // true, nil —— 连续星号折叠成单个 *,不报错(实测)
_, err = filepath.Glob("[")                   // syntax error in pattern —— 真·坏语法才报 ErrBadPattern(实测)

实测结论:

  • ** 不是递归通配符,而且不报错。实测 filepath.Match("a**c", "abc") 返回 true, nil——scanChunk 把连续的 * 折叠成一个(a**c 与 a*c 语义相同)。Glob("t/**/*.go") 因此等价于 "t/*/*.go":恰好匹配一段任意路径分量,实测命中 t/nested/x.go,比它深的 t/nested/deep/c.go 和比它浅的 t/y.go 都不中。要递归只能 WalkDir + 自己判后缀(doublestar 这类第三方库就是补这个能力的),别指望 ** 的"静默降级"替你做事。
  • * 拒绝跨越的是 filepath.Separator,而它按平台取值:Windows 上 / 只是普通字符,实测本机 Match("a**c", "a/c") 为 true;path.Match 则永远把 / 当分隔符(源码里 star 重试循环遇到 / 直接放弃)。同一份"含斜杠模式 + 含斜杠名字"的匹配,在两个平台可以得出相反结论——FS 风格路径(/ 分隔)要匹配就用 path.Match 或先 filepath.ToSlash 归一再比。单 * 不跨当前平台分隔符这点仍成立:实测 Match("a/*/c", "a/b/c/d") 为 false、对 "a/b/c" 为 true。
  • 语法非法([ 未闭合等)时 Glob/Match 返回 filepath.ErrBadPattern,实测文本 syntax error in pattern。两个相关事实:io/fs 没有 ErrBadPattern(引用它直接编译报 undefined: fs.ErrBadPattern);filepath.ErrBadPattern 与 path.ErrBadPattern 是文本相同但不同对象的两个哨兵(实测 filepath.Glob 的错误 == path.ErrBadPattern 为 false),跨包用 == 或 errors.Is 比对会全军覆没。匹配失败但模式合法时只是 false, nil,别用 err == nil 当"匹配上了"。
  • Glob 只匹配实际存在的文件(它会做目录列举),无匹配时返回空切片、err 为 nil;fs.Glob(fsys, pattern) 对含 .. 的 pattern 报 pattern contains path separator/非法路径类错误(实测:fs.Glob 要求 pattern 是合法 FS 路径)。
  • 需要大小写不敏感匹配(Windows 用户习惯)时标准库不提供,只能两边 strings.EqualFold 自己比。

5. EvalSymlinks:解析符号链接,但别指望它做防护

p, err := filepath.EvalSymlinks("t/../t/a.go") // 实测 -> 绝对路径的规范形式,err=nil
_, err = filepath.EvalSymlinks("no-such-path") // 报错(它要求路径存在)

它做三件事:解析路径上的每个符号链接、返回规范化结果、要求路径真实存在(不存在就报错)。所以:

  • 拿它做"路径是否存在 + 规范形式"的校验可以,但它是TOCTOU 不安全的:检查完成到真正打开之间,别人可以把某个成分换成符号链接。安全边界要靠 os.Root(内核层面的限制),不是靠先 EvalSymlinks 再 Join。
  • 返回的是绝对路径,跨平台下可能与原输入的相对形状不同,别把结果直接回显给用户。
  • 对 Windows 的 junction/卷挂载点,它同样按符号链接处理,可能返回完全不同的路径。

6. Windows 特殊性(跨平台代码迟早撞上)

  • 保留设备名:NUL、CON、PRN、AUX、COM1–COM9、LPT1–LPT9、CLOCK$ 作为文件 basename 时创建会失败,且与扩展名无关(nul.txt 也算)。实测 filepath.IsLocal("com1") 与 IsLocal("con") 为 false,而 IsLocal("com1.txt") 为 true——也就是说 IsLocal 拦了裸保留名,带后缀的没拦(因为它按"是否像保留名+扩展"的宽规则处理,跨版本细节不要依赖)。用户提供的文件名要落地时,最省事的办法还是自己生成名字。
  • 大小写不敏感:/a/B 与 /a/b 是同一文件,但 filepath.WalkDir 返回的名字大小写来自文件系统实际记录。字符串比较路径前要 strings.EqualFold 或两边统一规范化。
  • 路径长度:普通 Win32 API 上限约 260(MAX_PATH),\\?\ 前缀可以绕过但有额外限制(必须是绝对路径、不做规范化)。filepath 的多数函数在长路径上会失败,遇到就报清楚而不是循环重试。
  • 盘符与 UNC:filepath.VolumeName(\server\share\a) 返回 \\server\share;相对路径没有"当前盘符的当前目录"这种概念,Go 的 Abs 按进程 CWD 处理。跨卷时 Rel 会报错(§1 实测),移动文件也要走"复制 + 删除"而不是 os.Rename。
  • os.PathSeparator 在源码里写成字面反斜杠很痛苦:用 filepath.Separator/filepath.ListSeparator 常量,或让 Join 代劳,别手写 "\\" 拼接。

7. path 与 path/filepath 的分工

场景 用哪个 原因
真实文件系统路径 path/filepath 分隔符、绝对性、大小写规则按 OS
URL、HTTP 路由、归档内路径 path 永远 /,跨平台稳定
zip/tar 里的成员名 path(或 filepath.ToSlash) 归档规范用 /;archive/zip 写入时也强制 /
fs.FS 里的名字 path.Clean 语义 + fs.ValidPath FS 契约不允许 \、..、绝对路径

需要把 URL 路径映射到文件系统时,先转成局部名字再拼:name := strings.TrimPrefix(u.Path, "/"),然后 filepath.FromSlash(name) + filepath.IsLocal(name) 检查。直接 filepath.Join(root, u.Path) 会把 .. 抵消掉(§1 的 Join 陷阱),是历史安全通告里反复出现的模式。


8. 陷阱汇总

# 现象 原因与对策
1 HasPrefix(full, base) 通过了却读到 base 外 前缀比较把兄弟目录算进去;Join 又静默抵消 ..。改用 filepath.IsLocal 或 os.Root(§2)
2 用户传 /etc/passwd 时拼出的路径怪 Windows 上 Join("/data/x", "/etc/passwd") 把它当普通片段拼接(§2.1)。IsLocal 会先拒绝绝对路径
3 Base("")、Ext(".hidden") 结果意外 分别是 "." 和 ".hidden"(§1)。判空/取名字要显式处理这两个边界
4 以为 ** 能递归匹配 ** 静默折叠成 *(实测不报错),Glob("t/**/*.go") 只匹配恰好三层(§4)。递归请 WalkDir;注意 Windows 上 * 还能匹配 /
5 Match 返回 false 被当成出错 合法模式不匹配时 err 为 nil(§4)。用 m && err == nil 判定
6 遍历别人目录时 panic 回调里先用了 d 再判 err,而 err != nil 时 d 可能为 nil(§3)
7 想跳过 node_modules 没生效 用 filepath.SkipDir,注意它对文件的作用域是"该目录剩余文件"(§3)
8 WalkDir 少扫了内容 它不跟随符号链接(§3)。树里的链接目录不会进入
9 EvalSymlinks 之后 Join 就安全了 TOCTOU:检查到打开之间链接可被替换(§5)。用 os.Root 做边界
10 Windows 上 os.Create("nul") 失败 保留设备名,与扩展名无关(§6)
11 路径比较大小写不稳定 Windows 大小写不敏感而 WalkDir 返回文件系统记录的名字(§6)
12 Rel 跨盘符崩溃 不同卷之间没有相对路径,实测报 Rel: can't make ...(§1)
13 归档/URL 里出现 \ 用了 filepath 处理非文件系统路径(§7)。改用 path 或 ToSlash
14 filepath.Clean 被当成"安全函数" 它只做语法整理,会抹掉 .. 但不会告诉你是否越界(§2.1)

9. 实战建议

输入校验一律 IsLocal,落地一律 os.Root。 这两件工具把"路径安全"从口头约定变成编译器与运行时都帮你的形状:上传目录启动时 OpenRoot 一次,之后所有读写都在受限对象上进行,新增代码没机会忘记检查(详见 os-file.md §4)。需要限制得更细就 root.OpenRoot("sub") 逐层收窄。

不要相信用户给的文件名。 落地时自己生成名字(Go 1.27 的 uuid 包最省事,见 ../01-基础核心包/uuid.md),把原始名存进数据库或元数据。这样保留名、超长名、..、NUL、编码怪名、大小写冲突一起消失。

遍历目录的默认写法是 WalkDir + 先判 err。需要跳过目录时返回 SkipDir;只是"这棵树别再往下走"而不影响其它分支时,SkipDir 用在目录上正合适。给遍历加"最多 N 个文件 / 最多深度 D"的保护,在扫描用户上传目录时能挡住深度嵌套或超大树(配合 ../09-性能与运维 类文档里的限额思路,本手册里对应 ../07-附录/最佳实践清单.md)。

Glob 用来做"少量、浅层"的文件挑选,别把它当 shell 通配。 ** 不存在(§4)、无匹配返回空、错误模式会返回 ErrBadPattern,这三条决定了它在复杂筛选场景不如手写 WalkDir + Match 可控。需要真正的 doublestar 语义时再考虑第三方库(并把它标成非标准库依赖)。

判断"是不是同一个文件"用 filepath.SameFile,它比较的是 inode/文件 ID,能正确处理符号链接与大小写差异带来的"两个字符串指向同一文件"。字符串比较路径只在纯词法场景(比如前缀属于哪个配置目录)里用,而且必须先 Clean。

把 Windows 差异当成一等公民。 只要代码可能在 Windows 上跑(本地开发、CI、桌面工具),就按 §6 的规则写:名字自己生成、不硬编分隔符、跨卷操作不依赖 Rename、大小写比较显式化。“我只在 Linux 部署"从来不是省略这些的理由,因为第一个踩坑的往往是写代码的机器。

相关阅读:os-file.md(os.Root/os.CopyFS/权限模型)、bufio.md(遍历到文件之后的读取)、compress-archive.md(解压时的 zip-slip 防护正是 IsLocal/os.Root 的主场)、fs-embed.md(fs.FS 的名字必须满足 fs.ValidPath)、../07-附录/常见陷阱汇总.md。