目录穿越防护,以及容器里怎么还原宿主机路径
第一件事:请求里的路径不能越界
一个「把本机目录当下载库对外提供」的服务,最危险的一行代码是路径拼接。
旧实现就这么写:base + path,于是传 ?path=/../../etc/passwd 能列出下载库之外的目录,
下载接口甚至能读取服务端的任意文件。
修法是三件事,一件都不能少:
// 来自 HTTP 请求的路径必须走这里:旧实现直接做字符串拼接(base + path),
// 攻击者只要传 path=/../../etc/passwd 就能列目录、甚至借助下载接口读取
// 服务端的任意文件(目录穿越)。
func SafeJoin(base, rel string) (string, error) {
if strings.ContainsRune(rel, 0) {
return "", fmt.Errorf("路径包含非法字符")
}
rel = filepath.FromSlash(rel)
rel = strings.TrimLeft(rel, string(filepath.Separator)+`/\`)
if rel == "" || rel == "." {
return filepath.Clean(base), nil
}
abs := filepath.Join(base, rel)
if !Inside(base, abs) {
return "", fmt.Errorf("路径越界: %s", rel)
}
return abs, nil
}
- 先拒绝 NUL:
\x00会让某些系统调用把字符串截断,绕过后面所有检查; - 统一分隔符并去掉前导斜杠:让
/a/b、a/b、a\b三种写法等价, 否则「绝对路径」与「相对路径」会走两条不同的校验路径; - 用
filepath.Rel判断结果是否还在 base 内(Inside),而不是靠字符串前缀判断 ——/root/download_lib是/root/download_library的前缀,但两者毫无关系。
回归测试要覆盖两个接口、两种写法:列表越界回 400、下载越界回 403,
并且断言响应体里不含 top secret —— 只断言状态码的话,
「返回了文件内容但状态码写错」这种回归是抓不到的。
第二件事:容器里怎么还原宿主机路径
服务跑在容器里,看到的永远是容器内路径(/root/download_lib)。
但用户关心的是「我该把文件放到宿主机的哪个目录」—— 那是页面上唯一有用的信息。
答案藏在 /proc/self/mountinfo:
func hostPathIn(mountInfo, abs string) string {
best, bestLen := "", -1
for _, line := range strings.Split(mountInfo, "\n") {
fields := strings.Fields(line)
if len(fields) < 5 { continue }
mountPoint := unescapeMountPath(fields[4])
if mountPoint == "/" {
// 容器根文件系统(overlay)那一条挂载点是 "/",它会匹配上任何路径,
// 但它的「挂载根」指的是容器自己的根,拼不出宿主机路径
continue
}
if !insidePath(mountPoint, abs) || len(mountPoint) <= bestLen { continue }
if p, ok := hostPathOf(fields, mountPoint, abs); ok {
best, bestLen = p, len(mountPoint)
}
}
return best
}
算法就两条:挂载点是目标路径的前缀,且在满足条件的记录里取最长的那条(最具体的挂载胜出)。 取第 4 个字段(挂载根)+ 目标相对挂载点的部分拼出宿主机路径。
三个平台差异
- Linux 原生 / macOS Docker Desktop:第 4 个字段本身就是宿主机路径;
- Windows Docker Desktop:走 9p/drvfs,这个字段没有盘符,
盘符藏在超级选项里(
aname=drvfs;path=C:\),要单独补回来; - 命名卷:挂载根是
/,拼出来没有意义,按「推断不出来」处理。
// Windows 的 Docker Desktop:第 4 个字段没有盘符,从超级选项里取回来
if drive := windowsDrive(fields); drive != "" {
full := hostRoot
if rel != "" { full += "/" + rel }
return drive + strings.ReplaceAll(full, "/", `\`), true
}
// 用 path 而不是 filepath:mountinfo 里的路径永远是 POSIX 风格,
// filepath 在 Windows 上会把分隔符换成反斜杠,拼出来的路径就不对了
full := path.Join(hostRoot, rel)
最后这个「用 path 而不是 filepath」值得单独说:mountinfo 是 Linux 内核的文本格式,
它的路径永远是 POSIX 风格,用 Windows 的 filepath 处理它只会把分隔符改错。
这类「跨平台但数据来源是固定格式」的地方,最容易顺手用错标准库。
还有个小细节:unescapeMountPath 要还原 mountinfo 的八进制转义(\040 空格、\011、\012、\134 反斜杠)。
路径里带空格时全靠它 —— 不处理的话,D:\My Files 会变成两段。
顺带:检索要有硬上限
下载库可能有几十万个文件,一次检索就能把响应体和前端 DOM 一起撑爆。
所以遍历用 WalkDir(不做多余 lstat、不跟随符号链接),并设 500 条上限,
达到上限返回 fs.SkipAll 提前终止。单条目读不到(权限不足、并发删除)就跳过,不影响整体。