Java 文件上传如何挡住路径穿越:Path.normalize、真实路径与原子落盘
来源:17golang原创
时间:2026-08-11 21:24:52 296浏览 收藏
线上附件接口最容易被低估的地方,不是“能不能收到文件”,而是服务端最后把它写到了哪里。把用户传来的 report/../avatar.png 直接交给 resolve,再用字符串前缀判断存储路径,往往会留下越界写入或覆盖文件的缝隙。
一套稳妥的 Java 处理链路应该先生成服务端专属的落盘文件名,再把目标路径做归一化处理并核对存储文件夹边界,文件内容写入隔离的临时文件,最后用一次受控移动完成落盘。下面用 JDK 的 Path、Files API 拆解整条处理逻辑。
要点速览
- 原始文件名只用于展示,不能决定服务端的存储路径和最终文件名。
normalize()只做语法层面的路径清理,文件夹边界要靠 Path 对象的startsWith再做二次核对。- 符号链接校验和写入临时文件是两个独立环节,不能用一次字符串判断直接代替。
- 最终文件用全局唯一名称保存,所有校验通过后再以受控移动替换业务侧的文件状态。
先看清楚:危险点在路径拼接,不在后缀判断
很多上传代码只做两步校验:取合法扩展名、拼接预设存储路径。问题是攻击者可控的不只是 .jpg 这一段,还可能传入恶意斜杠、父文件夹标记、绝对路径甚至预先构造好的软链接。
Path uploadRoot = Paths.get("/srv/app/uploads");
Path target = uploadRoot.resolve(originalName);
Files.copy(input, target);
当 originalName 包含父文件夹跳转片段时,target 很容易跳出预设的上传文件夹范围。就算提前加了 normalize() 处理,也只能说明路径文本被整理过,不代表磁盘上实际存在的符号链接风险已经被消除。
用 Path 做文件夹边界校验
第一步就是让服务端完全掌控文件名和存放位置。用户上传的原始文件名可以单独存到数据库做前端展示,但物理落盘的文件名必须由服务端生成,比如用随机标识拼接经白名单校验过的扩展名。路径校验要直接操作 Path 对象对比边界,不要拿未经处理的字符串做判断。

static Path safeTarget(Path root, String extension) {
String storedName = UUID.randomUUID() + extension;
Path absoluteRoot = root.toAbsolutePath().normalize();
Path candidate = absoluteRoot.resolve(storedName).normalize();
if (!candidate.startsWith(absoluteRoot)) {
throw new IllegalArgumentException("upload path rejected");
}
return candidate;
}
这里有两个容易踩坑的细节。第一,root 和待校验的候选路径必须处在同一种绝对/相对路径语境下;第二,/srv/app/uploads-old 不能被当成 /srv/app/uploads 的合法子文件夹,所以要用 Path.startsWith 做路径前缀判断,不要用普通字符串的 startsWith 方法直接匹配。
normalize 之后,还要处理符号链接
Oracle 官方文档明确区分了 normalize() 和 toRealPath():前者清理 .、.. 这类路径里的冗余片段,属于纯语法层面的操作;后者才会结合当前文件系统解析出文件对应的真实物理位置。如果上传存储文件夹允许其他进程随意创建文件,绝对不能只把 normalize 的结果当成路径安全的唯一证明。
生产环境的常规实现可以提前把上传根文件夹在部署阶段创建好,同时给进程配置最小权限,服务端只能往这个专用存储位置写入文件;每次落盘前再检查待写入文件的父文件夹,确认它仍旧是预期的上传存储位置。对于已经存在的同名目标,使用不覆盖的创建选项,让意外出现的重名文件直接报错终止。
Path parent = target.getParent();
if (parent == null || !Files.isDirectory(parent, LinkOption.NOFOLLOW_LINKS)) {
throw new IllegalStateException("upload directory unavailable");
}
try (OutputStream out = Files.newOutputStream(
temp,
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE)) {
input.transferTo(out);
}
这一步的目标不是靠一行判断覆盖所有极端并发场景,而是把文件夹权限约束、目标唯一性校验、不跟随符号链接的明确意图全部写到代码逻辑里。高风险业务场景还可以把上传存储文件夹放在禁止直接执行、禁止被外部直接访问的隔离存储区,由独立的下载接口统一对外提供文件访问能力。
先写临时文件,再完成一次受控移动
直接把请求流往最终文件地址写,会让半截未校验完的文件提前暴露给下载接口,后续出错的失败恢复流程也会变得很麻烦。更稳妥的执行顺序是:在同个上传存储文件夹下创建全局唯一的临时文件,写完内容之后手动关闭流,再做文件大小和文件真实类型校验,全部通过之后移动到最终的正式文件名。

Path temp = Files.createTempFile(absoluteRoot, "incoming-", ".part");
try {
try (InputStream in = input) {
Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
}
verifySizeAndType(temp);
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
} catch (Exception ex) {
Files.deleteIfExists(temp);
throw ex;
}
ATOMIC_MOVE 能保证同一文件系统中的所有观察者,看到的都是完整的正式文件,不会出现中间半写状态。它也不是跨存储卷的万能方案:如果临时文件夹和最终存放的文件夹不在同一个文件系统下,移动操作可能退化成文件复制甚至直接报错,部署阶段要把这个约束加到服务启动自检项里。
把上传安全检查收敛成发布前清单
| 检查点 | 建议 | 失败处理 |
|---|---|---|
| 文件名 | 服务端生成唯一落盘名 | 原始文件名只存入展示字段 |
| 路径 | absolute + normalize + Path.startsWith | 直接拒绝请求并记录完整原因 |
| 存储位置 | 专用存储文件夹、最小权限、不可直接执行 | 阻止写入并触发告警 |
| 内容 | 限制单文件大小,校验文件真实类型 | 清理已生成的临时文件 |
| 落盘 | CREATE_NEW 创建临时文件后做受控移动 | 异步回收未处理的临时文件 |
测试阶段不要只传一张正常图片就收尾。至少要覆盖带父文件夹跳转片段的路径、绝对路径、同名文件、超限文件、伪造后缀的错误类型、上传中途中断这些场景,再额外启动一个并发任务尝试替换存储文件夹内的软链接,确认服务不会把最终文件写到预设的存储位置之外。
常见问题
只检查扩展名,能挡住路径穿越吗?
不能。扩展名管控解决的是允许什么类型的文件上传,路径穿越防护解决的是文件能写到什么位置,两个校验必须分开独立实现。
normalize() 之后还需要 toRealPath() 吗?
需要根据实际文件系统场景判断。normalize 只做文本层面的路径清理;如果涉及已有文件夹、符号链接或者对外提供的下载路径,要额外核对真实物理路径和存储文件夹权限。
临时文件一定要放在最终文件夹吗?
如果要实现原子移动,最好放在同一文件系统下的受控存储位置中。跨存储卷的移动会破坏原子性,错误表现也会变得不可控,不能默认两者效果等价。
为什么不直接保留用户原始文件名?
原始文件名适合做页面展示或者审计字段,不适合直接作为物理存储的文件名。服务端生成唯一名称可以大幅减少文件覆盖、特殊字符转义异常、路径解释歧义带来的各类风险。
结语:安全落盘是一个顺序问题
Java 文件上传接口的防护关键,不在于堆叠更多复杂正则,而是把固定的安全执行顺序落到代码里:服务端生成落盘文件名、Path 对象做边界校验、文件夹与符号链接检查、临时文件写入、类型与大小核对、受控移动。每一步都留好对应的失败处理分支,线上出问题之后排查和回滚都有明确抓手。
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
文章 · java教程 | 3天前 | Stream · Java教程 · 性能边界 · mapMulti · flatMap · Java mapMulti Stream flatMap Java Stream 条件展开 Java 16250 收藏
-
150 收藏
-
125 收藏
-
242 收藏
-
文章 · java教程 | 3天前 | 故障排查 · Java教程 · JFR · 虚拟线程 · 并发性能 · synchronized reentrantlock 虚拟线程 JFR Java 21 VirtualThreadPinned343 收藏
-
434 收藏
-
264 收藏
-
394 收藏
-
215 收藏
-
200 收藏
-
352 收藏
-
425 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习