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

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

来源:17golang原创

时间:2026-08-11 21:24:52 296浏览 收藏

线上附件接口最容易被低估的地方,不是“能不能收到文件”,而是服务端最后把它写到了哪里。把用户传来的 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>