路径以根目录开头就安全吗:pathlib 的词法归属与解析边界
摘要:把外部文件名拼到根目录后,再检查字符串前缀,容易把目录名称相似误当成文件归属。本文用 Python 标准库的本地合成实验,比较字符串、路径分段和解析后检查,并给出仅适用于可信目录树中已有文件的短例子。
离线查看器若约定“只能选择 uploads 里的文件”,最先要明确的是检查对象:一段路径文字,还是解析后的目标位置。两者通常相同,却会在父目录标记和目录链接处出现差异。
字符串前缀没有目录分隔的含义
本次在一个全新实验目录内创建 uploads 与 uploads-backup,分别放入合成文本文件。兄弟目录文件的完整路径虽然以 uploads 的完整路径字符串开头,却不属于 uploads。
直接使用 str(candidate).startswith(str(root)),本次对这个兄弟目录返回真。增加尾部分隔符可以修补这一种名称前缀误判,但仍没有处理 .. 和目录链接,不能由此得到通用的归属检查。
Path.is_relative_to() 按路径分段判断关系,能把 uploads-backup 与 uploads 分开。不过它是词法判断,不访问文件系统,也不赋予 .. “回到上一级”的实际解析含义。
本次 root / "../uploads-backup/secret.txt" 仍通过未解析的 is_relative_to(root)。原因是这个路径的前几段确实是 root,后面的 .. 还只是一个路径段。
先解析,再判断归属
resolve(strict=True) 将路径变为绝对路径,消解父目录段并解析符号链接;要求相关路径存在,解析失败就抛出异常。本次对根和候选都解析后再判断,得到以下结果。表中各路径都位于同一个新建的合成目录树内:
| 候选位置 | 字符串前缀 | 未解析的分段判断 | 解析后的分段判断 |
|---|---|---|---|
| uploads/docs/ok.txt | 真 | 真 | 真 |
| uploads-backup/secret.txt | 真 | 假 | 假 |
| uploads/…/uploads-backup/secret.txt | 真 | 真 | 假 |
| uploads/shortcut/secret.txt,链接指向兄弟目录 | 真 | 真 | 假 |
链接这一行没有 ..,仍然越界,说明搜索输入中的父目录标记并不足够。反过来,docs/../docs/ok.txt 解析后仍在根内,本次被接受;本例判断最终位置,没有禁止所有含 .. 的拼写。若业务要求输入只能采用规范相对名称,应另加命名规则。
一个有明确前提的可运行例子
下面只接收相对路径,只返回已有普通文件。根目录及解析后路径沿途的目录、链接和目标文件,必须在解析、检查到后续打开期间由可信方控制。完整代码可直接运行,示例内容全部自行生成:
from pathlib import Path
from tempfile import TemporaryDirectory
def checked_file(root, name):
base = Path(root).resolve(strict=True)
if not base.is_dir():
raise ValueError("expected root directory")
relative = Path(name)
if relative.anchor:
raise ValueError("expected relative path")
target = (base / relative).resolve(strict=True)
if not target.is_relative_to(base):
raise ValueError("outside root")
if not target.is_file():
raise ValueError("expected regular file")
return target
if __name__ == "__main__":
with TemporaryDirectory() as folder:
root = Path(folder) / "uploads"
sibling = Path(folder) / "uploads-backup"
root.mkdir()
sibling.mkdir()
(root / "ok.txt").write_text("synthetic", encoding="utf-8")
(sibling / "secret.txt").write_text("synthetic", encoding="utf-8")
for name in ("ok.txt", "../uploads-backup/secret.txt", "missing.txt", "."):
try:
path = checked_file(root, name)
print("接受", path.name)
except (ValueError, OSError) as exc:
print("拒绝", name, type(exc).__name__)
本次依次接受 ok.txt,拒绝越界路径、不存在文件和根目录本身。根本身也要检查为目录,否则把一个文件当根、输入 . 时可能错误通过。后续应使用返回的解析后路径,避免又拼回未经检查的原始名称。
根目录应来自调用方可信配置,不能让输入者同时决定根和文件名,再把“位于自己提供的根内”当成授权。这个函数只回答路径与指定根的关系;用户能否读取其中某个文件,仍应由业务权限决定。解析异常也应向上层报告,不能捕获后回退为直接打开原始路径。
Windows 的相对路径还有细节:C:ok.txt 带盘符但不是绝对路径,\ok.txt 只有根标记,同样不能只靠 is_absolute() 排除。本次五组纯 Windows 路径观察中,这两种写法的 anchor 都非空,因此示例统一拒绝带 anchor 的输入。UNC 字样仅作词法观察,没有访问网络共享。
检查结论停在什么位置
归属判断不是沙箱,也不是原子文件系统快照。若其他进程能在检查后替换目录链接,再打开同一个路径仍可能到达不同对象。面对不可信写入者,应另行选择操作系统提供的句柄、目录约束和权限隔离方案,不能把本例直接作为下载服务的安全边界。
普通文件判断也不证明内容来源。硬链接可能让多个名字对应同一文件;Windows 备用数据流、特殊名称和不同大小写配置的卷还需要独立约定。本次没有验证这些情况,也没有实现不存在目标的新建文件流程。
选择严格解析,是因为本例明确要求目标已存在。上传新文件时,最终文件尚不存在,不能仅删除严格参数就沿用同一承诺;创建位置、覆盖策略以及打开时的并发约束,都需要针对写入另行设计。
2026年11月6日,在 Windows、Python 3.12.14 下,七组路径观察、一组根类型拒绝、五组 Windows anchor 观察均符合预期,另运行了文中代码。两个子进程退出0,stderr为空,原始输出、退出码与指纹已保存。全部实验仅使用本地合成文件,未接入云服务,未测并发替换或生产系统。
- 点赞
- 收藏
- 关注作者
评论(0)