Java常用类库-26-路径规范化与真实目录边界
文件名处理必须区分文本与文件系统
商品导入允许提交报表文件名,应用准备把文件写入一个专用目录。对 reports/../catalog.csv 做字符串规范化,可以得到 catalog.csv;这个结果表达了词法路径,却没有说明文件系统中的 reports 是否为符号链接,也没有说明 catalog.csv 是否已存在。字符串干净与写入位置受控,是两个不同的验收条件。
本篇使用 Commons IO 2.22.0,固定提交 c14acc16f73e44a75b2062b17aacb26c4feda746,并用 Java 8 NIO 完成实际目录检查。实验策略刻意收窄:只接受私有目录下的新相对文件名,不覆盖已有对象,拒绝父路径与反斜杠,不允许不可信并发写者。这个边界用于后续受控解包示例,不宣称是任意共享目录的通用安全写入器。
完整测试见 Chapter26Test.java,辅助实现见 SafePaths.java,命令见 RUN.md。测试使用独立临时目录,实际创建符号链接与已有文件;没有把只打印一个规范化字符串当成落盘验证。
normalize 会消除语法,也会消除意图线索
FilenameUtils 固定源码处理分隔符、重复分隔符、当前目录和父目录等文本形式,不查询目标文件系统。实验表明,normalize("safe/../report.csv", true) 得到 report.csv;试图从相对路径起点再上溯的 ../report.csv 返回 null;反斜杠形式可以按指定规则转换为正斜杠。
如果业务政策是“禁止输入包含父路径段”,规范化之后再查 .. 已经太晚。safe/../report.csv 中的父路径段被消去了,剩下的字符串无法说明用户原来提交了什么。因此,政策检查应根据需要在原始输入上进行,规范化只承担统一表示的职责。
也有业务允许用户输入相对导航,然后将最终位置限制在根内。那种政策与本例不同,需要在允许导航的前提下计算目标,再检查实际边界。两者都必须明确规定,不能让 normalize 的返回结果隐式决定产品规则。这里选择拒绝父路径,是为了让上传与解包条目的可接受名称更容易审计。
| 输入 | 文本工具可能得到的结果 | 本例写入政策 |
|---|---|---|
safe/../report.csv |
report.csv |
拒绝父路径段 |
../report.csv |
null | 拒绝 |
safe\\report.csv |
safe/report.csv |
拒绝反斜杠 |
/tmp/report.csv |
绝对路径文本 | 拒绝 |
new/report.csv |
相对路径 | 继续文件系统检查 |
第一项可迁移模式是把表示转换与授权判断分开。转换函数负责生成某种形式,授权规则负责决定这种输入是否允许。把它们合成一个“sanitize”方法名,容易遗漏被转换过程抹掉的原始特征。
Path.startsWith 比字符串前缀更接近目录关系
字符串 /tmp/root-other/file 以 /tmp/root 开头,但它不在 root 目录内。目录边界应使用 Path 的分段关系,而不是普通字符串 startsWith。SafePaths 将可信根转成真实路径,再 resolve 相对名称并 normalize,要求目标 Path.startsWith(root),而且目标不能就是根自身。
Java 8 Path 文档区分 normalize 与 toRealPath。前者不访问文件系统,只整理冗余名称;后者解析实际存在的路径,并按选项处理链接。目标新文件尚不存在,所以不能直接对最终目标调用 toRealPath;实现检查已存在或刚创建的父目录,再使用 CREATE_NEW 创建最终文件。
相对路径、绝对路径与跨平台分隔符也不能只按当前机器的表现判断。在 Unix 上,反斜杠可以只是普通字符;换到 Windows 后,相同文本可能表示分隔符。上传协议如果只允许斜杠,拒绝反斜杠比依赖本机 Path 解释更明确。冒号也被本例拒绝,以收窄盘符和其他平台相关名称形式。
这项策略不会声称接受所有合法操作系统文件名。它为了跨环境导入选择较小的名称集合。若应用确实需要冒号或其他特殊字符,应先更新名称协议,再补目标平台的文件系统测试,不能只删掉一条检查就假定行为仍一致。
符号链接让词法位置与真实位置分离
实验在根目录中创建 link,指向根外的另一个临时目录。名称 link/escape.csv 在词法上仍位于 root 下,但沿符号链接打开它会写入外部目录。测试要求 SafePaths 拒绝,并确认根外 escape.csv 没有出现。
实现逐段处理父目录,检查 NOFOLLOW_LINKS 下是否存在对象。如果不存在,就创建普通目录;如果已经存在,则要求它是目录且不是符号链接。最后对父目录调用 toRealPath,并再次验证它位于真实根路径之下。词法边界与实际父目录边界分别检查,二者互不替代。
1 | |
符号链接并非天然非法,系统管理工具可能需要处理它;本例拒绝是因为任务只需要新建普通数据文件,没有必要引入重定向语义。允许链接之后,还需要决定相对链接目标、链接链、断链与循环如何处理。对于商品报表导入,这些额外能力没有业务收益。
硬链接与符号链接也不同。通过既有文件路径写入可能影响另一个硬链接名称指向的同一文件对象,因此“不跟随符号链接”仍不能替代“不覆盖已有对象”。本例统一拒绝已存在目标,避免直接接管未知对象。硬链接攻击的真实跨平台范围没有在本组测试中穷举,正文只解释拒绝既有对象所减少的行为范围。
既有文件策略需要真正落到打开操作
实验预先写入 old.csv,内容为一个值 7 的字节。解析同名目标必须失败,随后读取 old.csv 仍为原内容。这个断言比“抛了异常”更强,它确认失败没有覆盖现有数据。对于合法的 new/report.csv,测试通过 CREATE_NEW 写入,再对新文件 toRealPath 验证边界。
SafePaths 返回一个 Path,并不独占该路径。如果调用者随后使用默认 Files.write,通常会允许创建或截断已有文件,从而削弱前面确定的不覆盖政策。调用方必须保留 CREATE_NEW 这一条件,让真正的创建动作也拒绝已有目标,而不是只在打开之前检查一次 exists。
即便如此,父目录在检查后被恶意进程替换,仍然可能导致检查与使用间的竞态。这个实现没有提供基于目录句柄的逐段打开,也没有用 SecureDirectoryStream 消除全部路径替换窗口。因此,使用前提是父目录可信、工作目录私有、没有不可信写者可以同时修改路径结构。这个前提是能力边界,不能埋在“安全路径”这个类名之后。
第二项可迁移模式是让检查与执行共享同一政策,并明确剩余竞态。先 exists 再普通写入不是不覆盖保证;在不受控共享目录中,先检查符号链接再打开也不是完整隔离。需要更强威胁模型时,应选择支持相应原子操作的文件系统 API 和运行权限,而不是继续叠加字符串判断。
工作目录也是资源,需要限定归属
路径正确但清理范围错误,同样可能破坏数据。失败清理应只作用于本次操作创建并拥有的私有目录,不能对用户提供的父目录递归删除。后续归档示例将为每次解包创建新临时目录,失败时删除该目录,成功时返回目录交由调用方接管。
清理遍历还需要避免跟随链接进入外部位置。Java NIO 的 walkFileTree 默认不启用 FOLLOW_LINKS;应用仍要保证清理根是自己保存的那一个对象范围,并避免把任意请求字符串重新解析成删除目标。本组测试只负责目标路径创建边界,递归失败清理由归档章节单独验证。
目录自动创建也存在策略选择。SafePaths 可以创建缺失父目录,但不会覆盖某个同名普通文件来“修复”路径。如果 new 已经是文件,再要求写 new/report.csv,应当拒绝。调用者不能把这个异常当成可自动删除障碍的理由,既有对象并不归当前导入操作所有。
Commons IO 与 NIO 的分工
一个容易遗漏的边界是根目录本身。SafePaths 首先要求可信根真实存在,目标必须是根内的新对象,不能把空名称或当前目录解释为整个根。若应用为了方便接受任意请求指定根目录,再调用同样的方法,安全前提已经改变:可信根应由服务端配置或本次任务创建,而不是由上传内容决定。
文件扩展名也不是内容类型授权。把名称规范化为 report.csv,只能得到一个名称;其中仍可能是其他格式的数据。路径检查通过之后,仍要在私有目录中执行格式解析与字段验证。不能因为路径阶段已拒绝越界,就省略内容阶段的检查。
长度与数量同样属于外围协议。SafePaths 没有为总路径长度、层级数或一次上传的名称数量提供独立配额;文件系统也可能在创建时拒绝过长名称。批量任务需要在调用前限制这些规模,并把创建失败当成整体任务失败处理,避免留下未经所有权管理的父目录。
FilenameUtils 适合处理不需要访问磁盘的名称格式,例如扩展名、分隔符和规范化表示。它不能告诉调用方路径当前指向什么。PathUtils 与 FileUtils 提供很多文件操作便利入口,但是否跟随链接、是否替换目标、谁负责清理,仍要逐个方法查看选项与契约。
本例最终采用 NIO Path、Files.isSymbolicLink、toRealPath 和 CREATE_NEW,是因为所需判断直接对应文件系统状态。引入更多工具包装不一定减少安全边界的复杂度;能清楚表达步骤的标准库调用更便于审阅。Commons IO 在这里的价值是展示文本规范化能力及其边界,而不是充当授权器。
平台差异也必须留在验证结果中。符号链接测试在本机 macOS 文件系统实际运行,JDK 8 与 JDK 21 都执行了同一创建和拒绝路径。没有运行 Windows,也没有证明 Windows 设备名、备用数据流、大小写折叠等全部规则已覆盖。拒绝反斜杠和冒号缩小了输入集合,但不能据此宣称完成跨平台安全认证。
实验结果与练习
三组测试分别覆盖文本规范化、危险路径名称矩阵、既有文件与真实符号链接。所有路径均位于测试临时目录,失败后外部文件不存在、已有文件内容不变,正常嵌套路径确实创建成功。测试没有发起权限提升或访问真实业务目录。
手算题:root 为 /tmp/export,输入为 /tmp/export-old/file,字符串前缀检查与 Path.startsWith 是否相同?前者可能误接纳,后者区分路径分段。改动练习是在 root 中先创建名为 new 的普通文件,再请求 new/report.csv,断言拒绝且 new 内容保持不变。不要为了通过测试删除这个既有文件。
| 判断关键词 | 可迁移模式 | 具体选择 |
|---|---|---|
| normalize | 表示转换不是授权 | 保留原始名称并独立执行政策 |
| 符号链接 | 词法位置与真实位置分开 | 检查父目录对象与真实根 |
| 已有目标 | 检查与执行使用同一规则 | CREATE_NEW,拒绝覆盖 |
| 共享目录 | 剩余竞态需要明确前提 | 私有工作目录,禁止不可信并发写者 |
可独立复跑的 Maven 项目见 下载 25–30 实验包,包含固定依赖配置、完整源码与 Maven Wrapper。
系列起点:可复现基线。

