登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  python教程

Pathlib 安全拼接用户路径:解析后再验证根目录

来源:17golang原创

时间:2026-10-08 13:22:18 463浏览 收藏

我第一次给文件下载接口补“安全路径”时,直觉写法是 BASE_DIR / user_path。代码很像在表达“把用户路径放到根目录下面”,但测试很快打脸:../../etc/hosts 可以向上走,符号链接可以把看似正常的子目录指到根目录外,而绝对路径片段甚至会让前面的根目录直接失效。

后来我把判断拆成三件事:应用自己持有可信根目录;用户只能提交相对路径;拼接后先调用 resolve() 得到真实的规范路径,再用 relative_to() 验证它是否仍属于根目录。顺序很关键——只检查字符串、只去掉 ..,或者只调用 resolve(),都没有完成授权判断。

可直接记住的结论
  • 拒绝绝对路径,因为 pathlib 遇到绝对片段时会忽略前面的路径段。
  • 根目录和候选路径都先 resolve(),让 .. 与已有符号链接暴露真实指向。
  • 用 candidate.relative_to(root) 做组件级归属判断;抛出 ValueError 就拒绝。
  • 读取现有文件优先使用 strict=True;创建新文件要验证已存在的父目录,并单独考虑竞态。

先明确目标:根目录是授权边界,不是字符串前缀

假设应用只允许下载 /srv/app/files 里的内容。这里的根目录必须来自配置或程序常量,不能让用户同时决定根目录和文件名。用户提交的值只是一段相对路径,例如 reports/2026/summary.pdf。

真正要验证的问题不是“字符串是否以 /srv/app/files 开头”,而是“文件系统解析后的候选路径,是否仍然是该根目录的后代”。这一区别能挡住三个常见误判:

输入或结构仅拼接的结果风险
../../etc/hosts表面仍带着根目录文本规范化后可能逃到根目录外
/etc/hosts绝对片段覆盖前面的根目录直接访问应用授权范围之外
public/latest/report.pdf词法上像正常子路径latest 若为外部符号链接,真实目标仍会越界

Python 官方文档明确说明:路径构造或 / 运算遇到绝对路径段时,会忽略之前的路径段;Path.resolve() 会让路径绝对化、解析符号链接并消除 ..。这两条正是安全拼接需要处理的边界。参考:Python 3.14 pathlib 文档。

看清安全拼接的完整结构

我现在会把实现拆成“输入形态、解析、归属”三层。第一层快速拒绝绝对路径,避免根目录被重置;第二层让文件系统解析路径;第三层才做授权判断。这样每个判断都有单一职责,错误日志也能说明究竟是哪一层拒绝了请求。

可信根目录、用户路径、resolve 结果与 relative_to 归属判断的静态结构图
图1:安全路径拼接的结构边界。应用持有可信根目录,用户只提供相对片段;候选经过 resolve 后,再由 relative_to 判断是否仍属于根目录。

一个可复用的读取型函数可以这样写:

from pathlib import Path


class UnsafeUserPath(ValueError):
    """用户路径超出应用允许的文件根目录。"""


def resolve_existing_under(root: Path, user_value: str) -> Path:
    # 根目录必须存在,并先解析成可信的真实路径。
    trusted_root = root.resolve(strict=True)
    supplied = Path(user_value)

    # 绝对路径会覆盖前面的根目录,因此直接拒绝。
    if supplied.is_absolute() or supplied.anchor:
        raise UnsafeUserPath("只接受相对路径")

    # strict=True 让目标不存在和符号链接错误立即暴露。
    candidate = (trusted_root / supplied).resolve(strict=True)

    try:
        # relative_to 按路径组件判断候选是否属于根目录。
        candidate.relative_to(trusted_root)
    except ValueError as exc:
        raise UnsafeUserPath("路径超出允许的根目录") from exc

    return candidate

这段代码没有用 startswith(),也没有靠删除 ../。它先得到解析后的两个 Path 对象,再让 pathlib 按当前平台的路径语义比较祖先关系。

阶段一:先拒绝会重置根目录的输入

Path('/srv/app/files') / '/etc/hosts' 的结果不是 /srv/app/files/etc/hosts,而是 /etc/hosts。这不是 pathlib 的漏洞,而是路径拼接的既定语义。因此,“我已经在前面加了根目录”不能作为安全保证。

is_absolute() 用来判断当前平台上的绝对路径,anchor 则暴露驱动器或根等锚点。示例同时检查两者,是为了把“用户只能提供普通相对片段”的契约写得更直白。要注意,Path 按运行平台解释路径:Linux 服务不会把所有 Windows 风格字符串都当作 Windows 绝对路径。如果接口协议允许跨平台路径语法,应在协议层固定一种格式并单独解析,不能期待本机 Path 猜测另一种平台。

也不要把 ~ 当成必须自动展开的用户主目录。Path('~/a.txt') 默认只是一个含波浪号的相对路径;只有显式调用 expanduser() 才会展开。文件下载接口通常不需要这一能力,少做一次扩展,边界反而更清楚。

阶段二:根目录和候选路径都要解析

PurePath.is_relative_to() 和 PurePath.relative_to() 本质上是词法判断,不会访问文件系统,也不会特殊处理尚未解析的 ..。所以不能直接拿原始的 root / user_value 去判断。官方文档也提醒:需要考虑符号链接时,应先调用 resolve()。

resolve(strict=True) 做了三件对本题重要的事:

  • 把相对路径变成绝对路径;
  • 消除 .. 组件;
  • 解析已有符号链接,暴露真实目标。

根目录本身也要解析。如果配置中的根目录经过符号链接指向真正的数据卷,而候选解析成真实路径后却仍与未解析的根目录比较,合法文件也可能被误拒绝。双方进入同一表示空间,归属判断才有意义。

Path.absolute() 不能替代 resolve()。官方文档说明,absolute() 只让路径绝对化,不做规范化,也不解析符号链接;本题恰恰需要后两项。

阶段三:用 relative_to 做组件级归属判断

字符串前缀会把 /srv/app/files-backup/a.txt 误认为位于 /srv/app/files 下,因为它们共享字符前缀。os.path.commonprefix() 也按字符工作,Python 官方文档直接警告它可能返回无效路径;如果使用 os.path,至少应选按路径组件工作的 commonpath()。

在 pathlib 里,我更偏向 relative_to():

def ensure_descendant(root: Path, candidate: Path) -> Path:
    try:
        # 能得到相对路径,说明 candidate 位于 root 内或等于 root。
        relative = candidate.relative_to(root)
    except ValueError as exc:
        raise UnsafeUserPath("路径不属于允许的根目录") from exc

    # 下载接口通常不允许把根目录本身当成文件。
    if not relative.parts:
        raise UnsafeUserPath("必须指定根目录内的具体文件")

    return candidate

如果业务允许访问根目录本身,例如列目录接口,就不需要第二个判断。归属检查和“目标必须是普通文件”也是两件事:返回路径后还应按业务要求检查 candidate.is_file()、允许的扩展名、租户权限和文件大小。这些规则不能被“路径在根目录内”替代。

阶段四:读取现有文件与创建新文件要分开

读取或下载现有文件时,strict=True 很合适:目标不存在、符号链接断裂或出现链接循环都会失败。调用方可以把 FileNotFoundError 转成 404,把越界的 UnsafeUserPath 统一转成不泄露服务器目录结构的 400 或 404。

创建新文件时,最终目标本来就不存在,不能原样使用读取函数。较保守的做法是让用户只提供父目录下的文件名,先解析并验证已存在的父目录,再拼接一个经过命名规则约束的叶子名:

def resolve_new_file_under(root: Path, user_value: str) -> Path:
    trusted_root = root.resolve(strict=True)
    supplied = Path(user_value)

    # 新文件仍然只接受相对路径。
    if supplied.is_absolute() or supplied.anchor:
        raise UnsafeUserPath("只接受相对路径")

    # 先验证已经存在的父目录,避免符号链接把写入点带出根目录。
    parent = (trusted_root / supplied.parent).resolve(strict=True)
    try:
        parent.relative_to(trusted_root)
    except ValueError as exc:
        raise UnsafeUserPath("目标父目录超出允许范围") from exc

    # 叶子名不能继续携带目录层级。
    if supplied.name in {"", ".", ".."}:
        raise UnsafeUserPath("文件名无效")

    return parent / supplied.name

resolve(strict=False) 也可以解析已有部分并把不存在的尾部附加回来,但它不会证明最终目标存在。对于上传、覆盖、重命名等写操作,我更愿意把“验证父目录”和“创建叶子文件”明确分开,因为权限、覆盖策略和原子创建参数都发生在最终打开阶段。

识别竞态与部署边界

路径验证通过,只说明“解析发生的那个时刻”候选位于根目录内。若攻击者能在验证之后、文件打开之前替换目录或符号链接,就可能产生 TOCTOU(检查与使用之间的时间差)问题。Path.resolve() 不是文件描述符级的授权锁,也不会冻结目录树。

目录穿越、绝对路径、符号链接、归属检查与文件打开竞态的静态边界图
图2:路径验证与文件打开的边界。..、绝对路径和符号链接都可能让候选指向根目录外;resolve 加目录归属判断能识别解析时的越界,但并发替换仍需更强的句柄级或隔离措施。

在普通业务里,如果文件根目录由应用拥有,外部用户不能创建目录或符号链接,这套 pathlib 模式已经能把典型目录穿越边界写清楚。若目录内容本身由不可信用户并发控制,则应缩小攻击面:

  • 上传区与系统文件、应用代码分卷或分容器隔离;
  • 使用随机对象 ID 映射服务器文件名,不把用户输入直接当路径;
  • 在支持的平台上使用相对目录句柄的打开方式,并结合禁止跟随符号链接的系统调用选项;
  • 以最低权限运行文件服务,即使越界判断失误也不能读取敏感目录。

OWASP 对 Path Traversal 的建议同样强调:不要让用户控制路径的全部组成;不得不使用用户输入时,应在文件 I/O 之前规范化。参考:OWASP Path Traversal。

常见误区:看起来过滤了,实际没有完成授权

误区一:删除所有 ../

手工替换容易遗漏平台分隔符、重复编码和其他规范化结果,而且会把安全判断变成一场不断补字符串规则的竞赛。应在协议边界完成一次明确解码,然后交给路径解析与组件级归属判断。

误区二:用 startswith 判断

字符串不知道目录组件,/safe/root-old 会共享 /safe/root 前缀。路径授权必须比较路径层级,不比较字符相似度。

误区三:只调用 resolve

resolve() 会告诉你候选真实指向哪里,但不会替你决定那里是否获准访问。解析之后还必须与可信根目录比较。

误区四:先验证扩展名就算安全

.pdf 只能说明名字后缀,不说明文件位于哪里,也不说明内容类型。目录归属、文件类型、租户权限和内容检查属于不同边界,应分别处理。

速查表:不同需求该选什么

需求推荐做法不能替代的检查
读取现有文件根与候选均 resolve(strict=True),再 relative_tois_file()、业务权限、内容策略
创建新文件解析并验证现有父目录,再约束叶子名原子创建、覆盖策略、竞态防护
仅做词法路径运算使用 PurePath它不访问文件系统,不能证明符号链接目标
需要公共路径用 os.path.commonpath()不要用按字符工作的 commonprefix()
不可信用户可改目录树目录句柄、禁止跟随链接、存储隔离单次 resolve() 不能消除 TOCTOU

上线前检查清单

  • 可信根目录是否只来自应用配置,而不是请求参数?
  • 是否明确拒绝绝对路径和带锚点的输入?
  • 根目录与候选是否在比较前都经过 resolve()?
  • 归属判断是否使用 relative_to() 或等价的组件级算法?
  • 读取现有目标时是否使用 strict=True?
  • 创建新目标时是否先验证真实父目录,并限制最终文件名?
  • 路径归属之后,是否继续检查文件类型、租户权限和业务规则?
  • 不可信用户能否并发替换目录或符号链接?如果能,是否采用句柄级或隔离方案?
  • 错误响应是否避免暴露服务器绝对路径?

常见问题

is_relative_to 可以直接检查用户原始路径吗?

不建议。它是词法判断,不会访问文件系统,也不会替你解析符号链接;原始路径还可能含有 ..。安全场景应先解析根目录和候选,再判断归属。

resolve(strict=False) 安全吗?

它会尽量解析已有部分,并保留不存在的尾部。它适合明确需要构造新路径的场景,但不会证明最终目标存在,也不能消除验证后到打开前的竞态。读取现有文件时优先用 strict=True。

为什么不用 os.path.commonpath?

commonpath() 按路径组件工作,可以做等价判断;pathlib 版本用 relative_to() 更容易同时得到相对路径并处理失败。真正应避免的是按字符工作的 commonprefix()。

Windows 下这段代码还能用吗?

可以按 WindowsPath 的本地语义工作,包括驱动器和大小写折叠规则。但接口若会接收另一平台格式,必须先定义协议语法,不能把跨平台字符串直接交给当前系统的 Path 猜测。

这套写法最值得保留的不是某一行 API,而是判断顺序:根目录由应用控制,用户输入先限制形态,候选再交给文件系统解析,最后才做目录归属授权。把解析和授权分开,代码评审时就能清楚看到每一道边界,也更容易为读取、创建和高对抗目录分别选择正确的实现。

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