乐于分享
好东西不私藏

路径处理那些年踩过的坑:源码视角防雷指南(第20篇)

路径处理那些年踩过的坑:源码视角防雷指南(第20篇)

大家好,我是kofer。

路径处理看似简单,实则暗藏杀机。
几乎每一个做过文件上传、静态资源服务、插件系统、配置文件加载的同学,都或多或少在路径拼接上踩过坑,轻则读错文件,重则目录穿越导致服务器被脱裤。

今天我们不讲泛泛的使用姿势,而是直接从 Go 标准库源码(基于 go1.23 ~ go1.24 时期)的角度,带你看清那些“看起来很安全,实际上很危险”的行为。

准备好了吗?我们直接开卷!

一、最经典的雷:filepath.Join + 用户输入 = 目录穿越之王

base := "/var/www/uploads"
filename := r.URL.Query().Get("file")           // 用户可控: ../../etc/passwd
path := filepath.Join(base, filename)

很多人以为 filepath.Join 会帮我们“清理”路径,其实它根本不负责安全。

我们直接看源码(path/filepath/path.go):

funcJoin(elem ...string)string {
for i, e := range elem {
if e != "" {
return Clean(joinNonEmpty(elem[i:]))
        }
    }
return""
}

核心逻辑其实委托给了 joinNonEmpty,而它最终会调用 Clean

但请注意 Clean 的真实行为:

funcClean(path string)string {
// 1. 如果是空字符串 → 返回 "."
if path == "" {
return Dot
    }

// 2. 是否已带卷标(Windows特有)
    rooted := IsAbs(path) || ...

// 3. 核心:分解成元素,处理 .. 和 .
var out []string
for _, elem := range split(path) {
switch elem {
case"", Dot:
continue
case ParentDir:   // ".."
iflen(out) > 0 && out[len(out)-1] != ParentDir {
                out = out[:len(out)-1]
continue
            }
        }
        out = append(out, elem)
    }

// 4. 最后重新拼接
return rebuild(rooted, out)
}

关键结论来了:

  • filepath.Clean("../../../etc/passwd") → /etc/passwd
  • filepath.Clean("aaa/../../../etc/passwd") → /etc/passwd
  • 但 filepath.Clean("aaa/../../../../../etc/passwd") → /etc/passwd(仍然逃逸)

也就是说,Clean 只做语法规范化,不做语义上的根目录限制

而 filepath.Join(base, userInput) 的典型错误写法是:

// 错误示范
safe := filepath.Join("/data/app", userInput)          // userInput = "../../../../etc/passwd"

经过 Join → Clean 后,很容易逃到根目录。

正确姿势(推荐写法)

funcsafePath(base string, name string)(string, error) {
// 先 Clean 用户输入部分
    cleanName := filepath.Clean(name)
// 如果 Clean 后以 / 开头(绝对路径)或包含 .. 开头 → 拒绝
if filepath.IsAbs(cleanName) || strings.HasPrefix(cleanName, ".."+string(filepath.Separator)) || cleanName == ".." {
return"", ErrDangerousPath
    }

    final := filepath.Join(base, cleanName)

// 最终再检查一遍,确保没有逃出 base
if !strings.HasPrefix(final, filepath.Clean(base)+string(filepath.Separator)) &&
        final != filepath.Clean(base) {
return"", ErrDangerousPath
    }

return final, nil
}

二、另一个隐藏很深的坑:filepath.Abs + 相对路径

p, _ := filepath.Abs("../config.yaml")   // 你以为是当前目录的上级?

filepath.Abs 源码:

funcAbs(path string)(string, error) {
if IsAbs(path) {
return Clean(path), nil
    }
    wd, err := os.Getwd()
if err != nil {
return"", err
    }
return Join(wd, path), nil
}

也就是说:**它依赖 os.Getwd()**。

而 go run main.gogo test、容器、systemd、supervisor、不同工作目录启动……这些场景下 os.Getwd() 结果都不一样。

血泪教训:永远不要在生产代码里依赖 filepath.Abs 来定位项目内的配置文件。

推荐做法:

  • 使用 runtime.Caller() 获取源码文件路径(最稳)
  • 或者编译时注入 -ldflags "-X main.buildDir=xxx"
  • 或者启动时通过环境变量/flag 传入根目录
funcprojectRoot()string {
    _, file, _, _ := runtime.Caller(0)
return filepath.Dir(filepath.Dir(file))  // 根据项目结构调整层级
}

三、Windows 与 Unix 跨平台那些恶心的差异

最容易翻车的几个点:

  1. 卷标问题(C:、D:)

    • filepath.IsAbs("C:abc") → false(Go认为不是绝对路径!)
    • filepath.IsAbs("C:\\abc") → true
  2. / 与 \ 混用

    • Windows 上 filepath.Join("a", "b/c") → a\b\c
    • 但 filepath.Join("a", "/b") → \b(变成了绝对路径!)
  3. Clean 对 .. 的处理在卷标下的边界

    • filepath.Clean(C:\aaa..\bbb) → C:\bbb
    • 但 filepath.Clean(C:aaa..\bbb) → bbb(丢失卷标)

建议:在处理用户输入时,永远先做 filepath.FromSlash() 统一转成系统分隔符,再处理。

四、总结:路径处理的“五要五不要”

要:

  1. 对任何用户可控输入先单独 Clean
  2. 检查 Clean 后是否以 .. 开头或已经是绝对路径
  3. Join 后做前缀校验(HasPrefix + 长度匹配)
  4. 优先使用 filepath.FromSlash 统一斜杠
  5. 生产中尽量不用 os.Getwd() 和 filepath.Abs

不要:

  1. 不要直接把用户输入喂给 filepath.Join(base, userInput)
  2. 不要相信 filepath.Clean 能防止穿越
  3. 不要在库/中间件里依赖当前工作目录
  4. 不要在 Windows 上手动拼接 “”
  5. 不要忘记卷标(尤其是 C:xxx 这种诡异写法)

最后留一个思考题给大家:

base := "/app/data"
user := "../../../../etc/passwd"
p := filepath.Join(base, filepath.Clean(user))
fmt.Println(p)                    // 输出什么?

答案在评论区等你来揭晓~

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 路径处理那些年踩过的坑:源码视角防雷指南(第20篇)

评论 抢沙发

6 + 3 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮