Spring Boot 文件上传怎么做才安全?流式落盘、路径校验与数据库一致性实战

文件上传看起来只是接收一个 MultipartFile,再调用 transferTo()。但线上真正容易出问题的地方,是上传大小失控、恶意文件名、覆盖已有文件、数据库记录与磁盘文件不一致,以及下载接口越权。本文从一个"业务附件"场景出发,构建可以审计、可以清理、可以扩展到对象存储的处理流程。

技术栈:Java 17、Spring Boot 3.5、Spring MVC、Spring JDBC、PostgreSQL;本文代码为可复用设计示例,未在独立工程执行。

一、先定义上传接口的边界

业务场景:用户给自己有权限的工单上传 PDF 附件,最大 10 MiB,允许下载和删除。

与"直接把文件放进 static 目录"相比,推荐流程是:

  1. 认证并验证用户有权操作目标工单。
  2. Servlet 容器限制整个 multipart 请求的大小。
  3. 服务端验证文件内容、实际读取字节数和允许的类型。
  4. 文件先写入受控临时目录,计算 SHA-256。
  5. 将文件移动到不对外暴露的持久化目录。
  6. 数据库保存文件元数据和业务关联。
  7. 下载必须重新校验权限,绝不直接暴露服务器真实路径。

这里的"允许 PDF"不等于文件安全。PDF 仍可能携带恶意内容;面向公网或允许预览的系统,还需要病毒扫描、隔离和安全的内容处理策略。

二、配置 Multipart 大小限制

Spring Boot 的 multipart 配置来自 spring.servlet.multipart。官方文档说明默认单文件限制为 1MB、请求限制为 10MB;业务需要明确覆盖。

yaml 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 12MB
      file-size-threshold: 0B
      location: /var/app/upload-tmp

max-request-size 应考虑表单字段和协议开销,不建议恰好等于单文件大小。location 是容器接收 multipart 时使用的临时目录,需确保目录存在且进程有权限。

反向代理还可能有独立限制。例如 Nginx 的 client_max_body_size 可能在请求到达 Spring Boot 前返回 413;生产排查要检查整条链路。

不要通过设置 -1 取消限制。如果确实需要超大文件,优先采用分片上传、对象存储直传、上传会话和独立配额。

三、为什么不能信任原始文件名?

下面这种写法不安全:

java 复制代码
Path target = uploadRoot.resolve(file.getOriginalFilename());
file.transferTo(target);

客户端提供的文件名可能包含路径分隔符、特殊字符、重名或伪造后缀。即使清理了文件名,也不应把它当作存储键。

推荐策略:

  • 原始文件名仅作为展示字段,限制长度并去掉控制字符。
  • 存储键由服务端生成随机 UUID,不保留用户提供的路径。
  • 所有文件写在固定、非公开、专用的存储根目录。
  • 存储目录权限遵循最小权限原则,不允许应用执行上传内容。
  • 下载时使用数据库里的存储键,不允许客户端传任意文件系统路径。
java 复制代码
String objectKey = UUID.randomUUID().toString() + ".bin";
Path target = storageRoot.resolve(objectKey);

这里的 .bin 是存储内部名称;下载时通过响应头提供安全处理后的展示文件名。

四、元数据表:文件状态必须可追踪

以 PostgreSQL 为例:

sql 复制代码
CREATE TABLE attachment (
    id UUID PRIMARY KEY,
    ticket_id BIGINT NOT NULL,
    owner_user_id BIGINT NOT NULL,
    original_name VARCHAR(255) NOT NULL,
    object_key VARCHAR(80) NOT NULL UNIQUE,
    content_type VARCHAR(100) NOT NULL,
    size_bytes BIGINT NOT NULL,
    sha256 CHAR(64) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_attachment_ticket
    ON attachment(ticket_id, status);

状态可以设计为 PENDING、READY、DELETING、FAILED。如果对象存储与数据库分别提交,状态机有助于处理部分成功。

不要把"数据库事务提交"误认为磁盘文件也自动回滚。文件系统和关系数据库之间没有天然的单个 ACID 事务。

五、核心实现:流式写入、限额和摘要

下面示例使用 Java NIO 写入私有暂存文件,不使用 getBytes() 将整个文件读入堆内存。

java 复制代码
public record StoredFile(
        String objectKey,
        long size,
        String sha256
) {}

@Service
public class LocalAttachmentStorage {

    private static final long MAX_BYTES = 10L * 1024 * 1024;
    private final Path root = Path.of("/var/app/attachments")
            .toAbsolutePath().normalize();

    public StoredFile store(MultipartFile file) throws Exception {
        Files.createDirectories(root);
        Path temp = Files.createTempFile(root, ".upload-", ".part");
        String key = UUID.randomUUID() + ".bin";
        Path target = root.resolve(key);

        long count = 0;
        MessageDigest digest = MessageDigest.getInstance("SHA-256");

        try {
            try (InputStream in = file.getInputStream();
                 OutputStream out = Files.newOutputStream(temp)) {
                byte[] buffer = new byte[8192];
                int read;
                while ((read = in.read(buffer)) != -1) {
                    count += read;
                    if (count > MAX_BYTES) {
                        throw new IllegalArgumentException("文件超出大小限制");
                    }
                    digest.update(buffer, 0, read);
                    out.write(buffer, 0, read);
                }
            }
            if (count == 0) {
                throw new IllegalArgumentException("不允许空文件");
            }
            Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
            return new StoredFile(
                    key, count, HexFormat.of().formatHex(digest.digest()));
        } finally {
            Files.deleteIfExists(temp);
        }
    }
}

这段代码有意保持最小化:调用方仍需检查权限、文件类型和数据库状态;生产环境要处理文件系统不支持原子移动的情况。由于暂存与目标都在同一根目录,通常更容易获得同一文件系统内的原子移动,但不能无条件保证。

如果 Files.move 已成功而数据库保存失败,目标文件会留下孤儿文件。这个问题不能只靠 finally 删除暂存文件解决。

为什么还要在读取时计数?

Servlet multipart 限制是第一道防线;业务读取时再次计数是防御性检查,避免后续更换接入方式时绕过业务约束。

MultipartFile.getSize() 可以用于提前拒绝,但不能替代实际读取时的大小控制。

六、文件类型校验:扩展名与 Content-Type 都不够

客户端提交的 Content-Type: application/pdf 可以伪造,report.pdf 的后缀同样可以伪造。

PDF 的常见文件签名是 %PDF-。可以在受控暂存文件上读取头部做基础识别:

java 复制代码
static boolean looksLikePdf(Path path) throws IOException {
    try (InputStream in = Files.newInputStream(path)) {
        return Arrays.equals(
                in.readNBytes(5),
                new byte[]{'%', 'P', 'D', 'F', '-'});
    }
}

文件签名只是初筛,不是安全扫描。对不可信文件,仍需考虑反病毒扫描、PDF 解析器漏洞、嵌入脚本、压缩炸弹和恶意文档。

建议将"可上传"与"可预览"分开:上传完成后先处于待扫描状态,通过安全处理后才允许在线预览。

如果业务只是提供下载,可以优先使用 Content-Disposition: attachment,降低浏览器直接执行或解析上传内容的风险。

七、数据库与文件系统不一致怎么办?

常见失败窗口:

阶段 失败 后果 补偿
暂存写入 超限或断线 临时文件残留 finally 清理
文件移动 目标磁盘满 没有正式文件 返回失败
文件移动成功 DB 插入失败 孤儿文件 记录并清理
DB 已提交 服务进程宕机 可能未响应客户端 幂等查询
删除 DB 成功 删除文件失败 垃圾文件 异步重试

小型系统可以先采用"文件写入成功 → DB 写入 → DB 失败时尝试删除文件"的补偿方案,但仍存在进程突然崩溃的窗口。

更稳健的方式是:

  1. 先创建 PENDING 元数据,分配上传 ID。
  2. 文件写入与安全检查完成后更新为 READY。
  3. 定时扫描长时间停留在 PENDING 的记录与孤儿文件。
  4. 删除时先改为 DELETING,异步删除对象并完成最终状态变更。

对于对象存储,还可利用生命周期规则清理未完成的临时对象。

重要原则:不要因为补偿失败而静默返回"成功"。 应记录操作 ID,并让后台能够定位和重试。

八、下载接口:权限校验必须重新执行

不要设计成 GET /download?path=/var/app/attachments/xxx。

更合理的接口是:

text 复制代码
GET /api/tickets/{ticketId}/attachments/{attachmentId}

服务端先根据认证用户、工单 ID、附件 ID 查询元数据并校验授权,再从私有存储读取内容。

java 复制代码
@GetMapping("/api/tickets/{ticketId}/attachments/{id}")
public ResponseEntity<Resource> download(
        @PathVariable long ticketId,
        @PathVariable UUID id,
        Authentication authentication) {

    Attachment meta = attachmentService.requireReadable(
            ticketId, id, authentication.getName());

    Resource resource = storage.load(meta.objectKey());
    ContentDisposition disposition = ContentDisposition.attachment()
            .filename(meta.originalName(), StandardCharsets.UTF_8)
            .build();

    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
                    disposition.toString())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
}

Attachment、attachmentService、storage 是业务抽象,示例不是独立可运行的完整项目。load 必须限制在受控根目录,禁止通过用户输入构造路径。

对敏感附件,应设置合适的 Cache-Control,并记录必要的下载审计信息。

九、上传 API 的错误码与幂等设计

建议明确区分:

  • 400:空文件、非法格式、缺少必要字段。
  • 401/403:未登录或无业务权限。
  • 413:请求超过大小限制。
  • 409:上传状态冲突或幂等键重复且内容不一致。
  • 500/503:磁盘、数据库、对象存储等内部故障。

上传超时后,客户端可能不知道服务端是否成功。可以为上传会话分配 uploadId,并提供状态查询接口,避免盲目重复创建附件。

若业务需要"同一个请求只能产生一条附件",可以使用经过身份绑定的幂等键和数据库唯一约束。仅依赖 SHA-256 去重并不等于幂等:同一文件可能被合法上传到不同工单。

十、测试要覆盖哪些真正危险的边界?

至少覆盖:

  1. 空文件和恰好等于上限的文件。
  2. 比上限多 1 字节的文件。
  3. 伪造 Content-Type 和错误 PDF 签名。
  4. 文件名包含 ../、反斜杠和控制字符。
  5. 两个并发上传使用相同原始文件名。
  6. 文件移动成功、数据库写入失败。
  7. 进程中断后残留 PENDING 的补偿。
  8. 其他用户尝试下载不属于自己的附件。
  9. 下载时附件已经被标记为删除。
  10. 文件不存在但数据库记录仍是 READY。

Spring MVC 层可用 MockMvc 测试请求限制与响应,但文件系统补偿、数据库事务和并发问题仍需要集成测试。

示例断言:

java 复制代码
mockMvc.perform(multipart("/api/tickets/1/attachments")
        .file(new MockMultipartFile(
                "file", "empty.pdf", "application/pdf", new byte[0])))
        .andExpect(status().isBadRequest());

这个断言依赖你的 Controller 和异常映射已实现空文件返回 400;本文未执行该测试,属于预期行为示例。

十一、常见问题

上传文件要不要存数据库 BLOB?

小文件且强事务一致性要求的场景可以评估 BLOB;大量附件通常更适合对象存储加数据库元数据。应综合备份、恢复、访问模式与成本选择。

文件名使用 UUID 就绝对安全吗?

不是。UUID 解决命名冲突和部分路径输入风险,不解决恶意内容、越权、目录权限与存储资源耗尽。

能否直接把 MultipartFile 上传到对象存储?

可以,但仍要做权限、大小、类型、状态管理和失败补偿。大文件可以考虑由服务端签发短期上传凭证进行直传,并在上传完成后验证对象属性。

上传成功但客户端超时怎么办?

不要立即重传并生成第二份业务附件。优先查询上传会话状态;必要时使用幂等键重试。

总结

安全文件上传不是一个 transferTo() 方法,而是从请求限制、可信存储键、内容验证、权限控制、失败补偿到下载审计的一条完整链路。

先明确存储和权限边界,再补齐状态机与故障恢复。真正上线前,要用异常注入和集成测试验证"部分成功"场景,而不是只测试正常上传。

官方参考资料

相关推荐
这个昵称不容易34 分钟前
旅游管理系统的设计与实现(SpringBoot + MySQL + Vue 前后端分离)
java·springboot·毕设
Rain的Java大神之路35 分钟前
LeetCode 37 解数独面试攻略:回溯 + 位掩码 + MRV,从暴力到毫秒级求解
java·后端·leetcode
howard20052 小时前
Spring Boot 版成绩统计分析
spring boot·成绩统计分析
天天被压力2 小时前
【A股分钟级高频数据完全手册 #11】券商实时行情数据接口:实时交易(券商源)接入与字段
java·人工智能·python
天天被压力2 小时前
【A股分钟级高频数据完全手册 #12】股票五档盘口数据接口:买卖五档实时盘口解析
java·人工智能·python
Wang's Blog11 小时前
Java 项目实战: 交友项目 - FastDFS:入门案例
java·开发语言·交友
apcipot_rain12 小时前
C++转Java日记(03)
java·开发语言·c++
皮皮林55112 小时前
Java实现微信扫码登录
java
Yyyyyy~14 小时前
【Java】Day4Web基础知识
java·spring boot