登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go filepath.Clean不能阻止路径越界时的防护边界

来源:17golang原创

时间:2026-09-20 11:34:54 265浏览 收藏

先给结论:filepath.Clean 只会整理路径字符串,它不会检查路径对应的文件,也不会替你决定“允许目录”。因此,filepath.Join(root, filepath.Clean(userPath)) 不能单独防止路径越界。更稳妥的做法是先把候选路径限制在根目录的词法范围内,再按业务需要处理符号链接和并发替换。

实用判断:清理负责得到稳定的路径形式,filepath.Rel 负责判断是否越出根目录;两者都通过后,才进入读取或下载动作。

filepath.Clean到底做了什么

Go 官方文档把 Clean 定义为词法处理:它会合并分隔符、消除当前目录项,并尽量消除父目录项。这个过程不访问磁盘,所以它不知道某个目录是不是符号链接,也不知道目标文件是否存在。

例如,输入 reports/../2026/a.csv 可以被整理成 2026/a.csv;但输入 ../../etc/passwd 仍可能保留向上层移动的含义。即使把它拼到 /srv/app/files 后再清理,结果也可能是 /srv/app/etc/passwd,已经不在预期的 files 内。

所以,Clean 的定位是“规范化字符串”,不是“授予访问权限”。把这两个职责混在一起,是路径越界防护最常见的误区。

先用filepath.Rel判断词法范围

下面的辅助函数把根目录转成绝对路径,再计算候选路径相对根目录的结果。相对结果等于 ..,或以 .. 加路径分隔符开头时,说明候选路径已经走出根目录。

package safejoin

import (
    "errors"
    "os"
    "path/filepath"
    "strings"
)

// ResolveInside 只做词法范围校验,不跟随符号链接。
func ResolveInside(root, userPath string) (string, error) {
    // 用户输入必须是相对路径,避免绝对路径直接替换 root。
    if userPath == "" || filepath.IsAbs(userPath) {
        return "", errors.New("path must be relative")
    }

    // Abs 会统一比较基准;Clean 只负责整理字符串,不是安全判断。
    base, err := filepath.Abs(root)
    if err != nil {
        return "", err
    }
    candidate := filepath.Join(base, filepath.Clean(userPath))
    rel, err := filepath.Rel(base, candidate)
    if err != nil {
        // Windows 不同盘符等情况无法形成相对关系,应直接拒绝。
        return "", err
    }
    if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
        return "", errors.New("path escapes root")
    }
    return candidate, nil
}

// ReadFileInside 在通过范围判断后才打开文件,并保留打开错误。
func ReadFileInside(root, userPath string) ([]byte, error) {
    path, err := ResolveInside(root, userPath)
    if err != nil {
        return nil, err
    }
    return os.ReadFile(path)
}

这里没有用字符串前缀比较。例如根目录是 /srv/app/files 时,/srv/app/files-old/a.txt 也带有相同前缀,却不属于根目录。Rel 的分段结果能避开这个边界。

Go 路径清理与 filepath.Rel 范围判断的静态结构说明图
图1:说明图,展示用户路径经过 Clean 后仍要由 Rel 判断是否越出允许根目录。

符号链接会把词法范围变成真实范围

解释词法路径在根目录内但符号链接解析后指向外部目录的边界。
图2:结构说明图,展示符号链接解析后需要重新判断真实路径范围。

Rel 判断的是字符串表达的路径关系。如果根目录内存在指向外部目录的符号链接,词法上位于根目录内的 cache/current.txt,打开时可能实际落到根目录之外。对“只能读取根目录实际内容”的场景,必须把符号链接纳入威胁模型。

// ExistingPathInside 校验已存在路径解析后的真实位置。
func ExistingPathInside(root, userPath string) (string, error) {
    candidate, err := ResolveInside(root, userPath)
    if err != nil {
        return "", err
    }
    realRoot, err := filepath.EvalSymlinks(root)
    if err != nil {
        return "", err
    }
    realCandidate, err := filepath.EvalSymlinks(candidate)
    if err != nil {
        return "", err
    }
    rel, err := filepath.Rel(realRoot, realCandidate)
    if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
        return "", errors.New("resolved path escapes root")
    }
    return realCandidate, nil
}

这个版本适合目标已经存在的读取场景。EvalSymlinks 自身也会清理结果,且目标不存在时会返回错误;如果业务是“允许创建新文件”,就要校验真实父目录、限制创建目录的写权限,并在创建动作中采用更严格的文件系统能力。先检查、后打开之间还可能发生 TOCTOU:攻击者替换了符号链接,单纯的两次函数调用不能保证原子安全。

把边界写进测试和业务清单

至少覆盖以下输入:ok.txt、sub/../ok.txt、../secret.txt、sub/../../secret.txt、绝对路径、空字符串、根目录前缀相似的 files-old/a.txt,以及指向外部目录的符号链接。每类输入都要区分“被拒绝”与“文件不存在”,这样日志和调用方才能正确处理。

最后把校验放在任何 os.Open、os.ReadFile、下载响应或模板渲染之前;不要用 URL 的 path.Clean 代替操作系统路径的 filepath.Clean,也不要把一段看似成功的清理结果当成授权结果。对于不可信输入和可写目录,优先采用操作系统提供的目录句柄、无跟随符号链接选项或沙箱能力。

常见问题

只判断字符串前缀可以吗?不建议。前缀相同不等于路径分段属于同一目录,filepath.Rel 更适合表达这个范围关系。

调用EvalSymlinks后就绝对安全吗?也不是。它能帮助判断已有路径的真实位置,但不能消除检查与后续打开之间的竞态;高风险写入场景仍需原子、受限的文件系统操作。

官方参考:https://pkg.go.dev/path/filepath

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>