当前位置:首页 > 文章列表 > 文章 > java教程 > Java 文件上传如何挡住路径穿越:Path.normalize、真实路径与原子落盘

Java 文件上传如何挡住路径穿越:Path.normalize、真实路径与原子落盘

来源:17golang原创 2026-08-11 21:24:52 0浏览 收藏

线上附件接口最容易被低估的地方,不是“能不能收到文件”,而是服务端最后把它写到了哪里。把用户传来的 report/../avatar.png 直接交给 resolve,再用字符串前缀判断存储路径,往往会留下越界写入或覆盖文件的缝隙。

一套稳妥的 Java 处理链路应该先生成服务端专属的落盘文件名,再把目标路径做归一化处理并核对存储文件夹边界,文件内容写入隔离的临时文件,最后用一次受控移动完成落盘。下面用 JDK 的 PathFiles 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 对象对比边界,不要拿未经处理的字符串做判断。

Java 文件上传路径校验:请求文件名经过 normalize 后进入文件夹边界检查,再决定拒绝或继续写入
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);
}

这一步的目标不是靠一行判断覆盖所有极端并发场景,而是把文件夹权限约束、目标唯一性校验、不跟随符号链接的明确意图全部写到代码逻辑里。高风险业务场景还可以把上传存储文件夹放在禁止直接执行、禁止被外部直接访问的隔离存储区,由独立的下载接口统一对外提供文件访问能力。

先写临时文件,再完成一次受控移动

直接把请求流往最终文件地址写,会让半截未校验完的文件提前暴露给下载接口,后续出错的失败恢复流程也会变得很麻烦。更稳妥的执行顺序是:在同个上传存储文件夹下创建全局唯一的临时文件,写完内容之后手动关闭流,再做文件大小和文件真实类型校验,全部通过之后移动到最终的正式文件名。

Java 文件上传安全落盘:请求流先写入临时文件,关闭并核对后移动到唯一最终文件
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 对象做边界校验、文件夹与符号链接检查、临时文件写入、类型与大小核对、受控移动。每一步都留好对应的失败处理分支,线上出问题之后排查和回滚都有明确抓手。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
Go 1.25 的 synctest 怎么测定时器:把异步重试从真实等待改成可控推进Go 1.25 的 synctest 怎么测定时器:把异步重试从真实等待改成可控推进
上一篇
Go 1.25 的 synctest 怎么测定时器:把异步重试从真实等待改成可控推进
废弃 MySQL 索引怎么安全下线:先设 INVISIBLE,再用 EXPLAIN ANALYZE 验证
下一篇
废弃 MySQL 索引怎么安全下线:先设 INVISIBLE,再用 EXPLAIN ANALYZE 验证
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • ljg-skills -
    ljg-skills
    ljg-skills 是李继刚开源的 AI 技能与提示词集合,面向大模型使用者整理了一批可复用的 prompt、角色设定和任务技能模板,适合用于学习提示词设计、搭建个人 AI 工作流和沉淀团队常用智能体能力。
    4837次使用
  • MELO音乐 - AI 音乐生成平台,支持多模态创作能力
    MELO音乐
    MELO音乐是一站式AI视频与音乐制作助手,对标suno, udio的高品质体验。提供伴奏生成、原创写词、无损导出、哼唱识曲、混音变声等全套音频与短视频编辑工具。无论是流行Kpop、电音说唱、民谣古风、摇滚儿歌还是商用轻音乐,MELO为你免费谱曲,轻松做同款!
    4424次使用
  • UniScribe - AI 免费在线音视频转文字平台
    UniScribe
    UniScribe 是一款 AI 音视频转文字与内容整理工具,支持上传音频、视频文件或粘贴 YouTube 链接,自动生成转写文本、摘要、思维导图和关键问题,并支持多格式导出,适合会议记录、课程学习、访谈整理和内容创作复盘。
    4367次使用
  • 剧云 - 免费 AI 智能中文剧本创作平台
    剧云
    剧云是专业中文剧本创作平台,安全稳定运行十余年,集成AI编剧、剧本医生审核、人物小传、剧情关系图、大纲编写、多人协作、Word导入导出、版权管控功能,数据安全防护,轻松高效创作剧本。
    4600次使用
  • 万象有声 - AI 一站式有声内容创作平台
    万象有声
    万象有声,一个专为有声创作者打造的新一代智能有声内容创作平台。平台提供专业的智能拆章、智能画本编辑、AI配音、AI生成音效、后期制作、智能对轨、智能审听等有声创作全流程工具,可以帮助创作者高效、低成本创作出引人入胜的有声作品。立即体验,让有声书制作更简单!
    4554次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议隐私政策
返回登录
  • 重置密码