Skip to content

文件上传下载与资源安全 ​

学习目标 ​

  • 能从 MultipartFile 读取上传元数据,在服务端重新生成文件名并执行扩展名、大小和内容白名单校验。
  • 能用规范化绝对路径限制存储根,识别路径穿越、符号链接、临时文件和拒绝路径的边界。
  • 能设置安全的 Content-Disposition,以流式响应下载大文件并处理成功、拒绝和中途失败。

核心知识点 ​

专业术语 ​

  • MultipartFile:Spring MVC 对 multipart/form-data 文件部件的抽象,包含原始文件名、媒体类型、大小和输入流。
  • transferTo:把上传内容落到指定 Path/File 的便利方法;目标路径仍由应用生成,不能直接信任用户文件名。
  • 扩展名/大小白名单:在业务允许范围内限制后缀、媒体类型和字节数;扩展名不是内容真实性证明。
  • 路径规范化(normalize):消除路径中的 .、.. 片段;要和绝对根目录比较,必要时还要校验真实路径和符号链接。
  • Content-Disposition:下载响应的内容处置头;附件文件名必须编码、裁剪并避免控制字符。
  • 流式响应(streaming response):边读边写响应体,避免把大文件一次性装入堆内存。

白话解释与边界 ​

上传流程应是“接收部件 → 限制大小 → 检查扩展名和媒体类型 → 读取少量魔数/内容 → 服务器生成存储名 → 规范化路径并落盘 → 记录元数据”。getOriginalFilename() 只用于展示或提取后缀,不能直接拼到存储路径;存储目录和公开下载 URL 也应分开。

文件名白名单、扩展名白名单和内容检测解决不同问题:.jpg 可能包含脚本或伪造内容,Content-Type 也由客户端提供,不能单独作为信任依据。超过大小上限、空文件、类型不允许或路径越界都应在落盘前拒绝,并返回稳定错误,不向客户端暴露服务器绝对路径。

下载时先根据受控文件 ID 查到存储路径,再确认路径仍位于根目录内;不能让请求直接传入任意文件系统路径。Content-Disposition 文件名只保留允许字符并使用 RFC 兼容编码。小文件可以使用 InputStreamResource,大文件用 StreamingResponseBody 或容器支持的零拷贝方式;流式传输中客户端断开属于可观测的 I/O 失败,不应把半个文件标成成功。

临时文件要放在受控目录,使用随机名和最小权限,成功移动后清理源文件,异常和取消路径也在 finally 删除。规范化路径不能自动防止符号链接跳出根目录,高风险目录还应比较 toRealPath、关闭跟随符号链接或由操作系统权限做最后一道边界。

依赖与版本基线 ​

本文按 Java 17、Spring Boot 4.1.0 与 Spring Framework 7 的 Web 项目组织示例,常见依赖是由 Boot BOM 管理的 org.springframework.boot:spring-boot-starter-web:4.1.0。Boot 4/Spring 7 使用 jakarta.servlet.*,旧版 Spring MVC 代码中的 javax.servlet.* 不能只改业务逻辑就混用;现代 MultipartFile.transferTo(Path) 与较早的 transferTo(File) 重载也要按实际 Spring 版本选择。无论调用哪个重载,目标都必须由受信 root、授权结果和服务器生成的存储名计算,不能把原始文件名当路径。

常用用法 ​

MultipartFile:读取上传元数据 ​

用途:用于在 multipart 请求边界读取大小、原始名和输入流,并在真正写入前完成基本拒绝判断。

java
import org.springframework.web.multipart.MultipartFile;

String describe(MultipartFile file) {
    if (file == null || file.isEmpty()) return "rejected:empty";
    String original = file.getOriginalFilename();
// 初始状态:original = file.getOriginalFilename()。
// 作用:String original = file.getOriginalFilename();;读取上传元数据。
    System.out.println("upload=" + original + "/" + file.getSize());
// 输出:upload=report.csv/12
    return "accepted";
}

// 作用:用于在 multipart 请求边界读取大小、原始名和输入流,并在真正写入前完成基本拒绝判断。

原始文件名和媒体类型都来自客户端,最多用于提示和初步筛选;服务端应重新生成存储名。请求解析器还要设置单文件和总请求大小上限,不能只依赖业务方法中的一次判断。

transferTo:在白名单目录落盘 ​

用途:用于把通过校验的 MultipartFile 写入应用生成的目标路径,避免直接信任用户提供的文件名。

java
final class SecureFileService {
    private final Path trustedRoot;
    private final FileAuthorization authorization;
    private final FileMetadata metadata;

    StoredFile store(MultipartFile file, String principal) throws Exception {
        if (!authorization.canUpload(principal)) throw new SecurityException("upload denied");
// 初始状态:if (!authorization.canUpload(principal)) throw new SecurityException("upload denied")。
        if (file == null || file.isEmpty()) throw new IllegalArgumentException("empty upload");
// 初始状态:if (file == null || file.isEmpty()) throw new IllegalArgumentException("empty upload")。
        String extension = extensionOf(file.getOriginalFilename());
// 作用:String extension = extensionOf(file.getOriginalFilename());;在白名单目录落盘。
// 初始状态:extension = extensionOf(file.getOriginalFilename())。
        if (!isAllowedSize(file.getSize())) throw new IllegalArgumentException("file too large");
// 初始状态:if (!isAllowedSize(file.getSize())) throw new IllegalArgumentException("file too large")。
        String serverName = UUID.randomUUID().toString();
// 作用:String serverName = UUID.randomUUID().toString();;在白名单目录落盘。
// 初始状态:serverName = UUID.randomUUID().toString()。
        Path target = resolveWriteTarget(serverName, extension);
// 初始状态:target = resolveWriteTarget(serverName, extension)。
        try (var input = file.getInputStream();
// 作用:try (var input = file.getInputStream();;在白名单目录落盘。
// 初始状态:input = file.getInputStream()。
             var output = Files.newOutputStream(target,
                     StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE,
                     LinkOption.NOFOLLOW_LINKS)) {
// 作用:var output = Files.newOutputStream(target,;在白名单目录落盘。
// 初始状态:output = Files.newOutputStream(target,。
            input.transferTo(output);
// 初始状态:input.transferTo(output)。
// 作用:input.transferTo(output);;在白名单目录落盘。
        } catch (FileAlreadyExistsException exists) {
            throw new SecurityException("target already exists", exists);
// 作用:throw new SecurityException("target already exists", exists);;在白名单目录落盘。
        }
        return saveMetadataOrDelete(serverName, target);
    }
}
// 输出:stored=<uuid-server-name>
// 作用:用于把通过校验的 `MultipartFile` 写入应用生成的目标路径,避免直接信任用户提供的文件名。

store 入口先做主体授权、空文件判断、扩展名白名单和大小白名单,再生成服务端文件名;extensionOf、isAllowedSize、resolveWriteTarget 和 saveMetadataOrDelete 是同一个 SecureFileService 的辅助方法。写文件使用 CREATE_NEW + NOFOLLOW_LINKS,成功后才写元数据,不使用 MultipartFile 的便利落盘方法。

执行上传白名单校验 ​

用途:用于把客户端原始文件名转换为受控后缀,并在落盘前统一执行字节数限制。

java
private static final Set<String> ALLOWED_EXTENSIONS = Set.of("csv", "txt");
// 初始状态:ALLOWED_EXTENSIONS = Set.of("csv", "txt")。
// 作用:private static final Set<String> ALLOWED_EXTENSIONS = Set.of("csv", "txt");;白名单只允许 csv、txt 扩展名。
private static final long MAX_UPLOAD_BYTES = 10_000_000L;
// 初始状态:MAX_UPLOAD_BYTES = 10_000_000L。

private static String extensionOf(String originalFilename) {
    String lower = originalFilename == null ? "" : originalFilename.toLowerCase(Locale.ROOT);
// 初始状态:lower = originalFilename == null ? "" : originalFilename.toLowerCase(Locale.ROOT)。
// 作用:String lower = originalFilename == null ? "" : originalFilename.toLowerCase(Locale.ROOT);;把原始文件名统一转为小写,null 按空串处理。
    int dot = lower.lastIndexOf('.');
// 初始状态:dot = lower.lastIndexOf('.')。
// 作用:int dot = lower.lastIndexOf('.');;dot 记录最后一个点的索引,无点时为 -1。
    String extension = dot >= 0 ? lower.substring(dot + 1) : "";
// 初始状态:extension = dot >= 0 ? lower.substring(dot + 1) : ""。
// 作用:String extension = dot >= 0 ? lower.substring(dot + 1) : "";;截取点后的扩展名,无点时 extension 为空串。
    if (!ALLOWED_EXTENSIONS.contains(extension)) throw new SecurityException("extension denied");
    return extension;
}

private static boolean isAllowedSize(long size) {
    return size > 0 && size <= MAX_UPLOAD_BYTES;
}
// 输出:csv/12 通过,exe 或超过 10000000 字节被拒绝
// 作用:用于把客户端原始文件名转换为受控后缀,并在落盘前统一执行字节数限制。

Locale.ROOT 避免区域设置改变后缀判断;扩展名和大小仍不是内容真实性证明,落盘前还要配合媒体类型、魔数和业务格式扫描。

metadata.save:写入记录失败时清理 ​

用途:用于保证文件写成功但元数据落库失败时删除已写文件,同时保留原始元数据异常。

java
private StoredFile saveMetadataOrDelete(String serverName, Path target) throws Exception {
    try {
        StoredFile record = metadata.save(serverName, target);
// 初始状态:record = metadata.save(serverName, target)。
// 作用:StoredFile record = metadata.save(serverName, target);;写入记录失败时清理。
        System.out.println("stored=" + record.id());
// 输出:元数据失败时删除目标,清理失败作为 suppressed 异常
        return record;
    } catch (Exception metadataFailure) {
        try {
            Files.deleteIfExists(target);
// 作用:Files.deleteIfExists(target);;写入记录失败时清理。
        } catch (Exception cleanup) {
            metadataFailure.addSuppressed(cleanup);
// 作用:metadataFailure.addSuppressed(cleanup);;写入记录失败时清理。
        }
        throw metadataFailure;
    }
}
// 说明:metadata.save(record) 抛异常时删除刚写入的 storedPath,再重新抛出原异常;若清理也失败,将清理异常作为 suppressed 保存而不覆盖落库根因。

清理失败只通过 addSuppressed 附加到原异常,不能用新的清理异常覆盖元数据失败;删除失败还应记录受控告警。这个 helper 由 store 在原子写入成功后调用。

resolveWriteTarget:检查生成路径 ​

用途:用于把服务端生成的名称限制在受信 root 内,并拒绝符号链接父目录。

java
private Path resolveWriteTarget(String serverName, String extension) throws Exception {
// 作用:private Path resolveWriteTarget(String serverName, String extension) throws Exception {;检查生成路径。
    if (!serverName.matches("[A-Za-z0-9_-]{1,64}")) throw new SecurityException("bad name");
// 作用:if (!serverName.matches("[A-Za-z0-9_-]{1,64}")) throw new SecurityException("bad name");;检查生成路径。
// 初始状态:if (!serverName.matches("[A-Za-z0-9_-]{1,64}")) throw new SecurityException("bad name")。
    Path candidate = trustedRoot.resolve(serverName + "." + extension).normalize();
// 初始状态:candidate = trustedRoot.resolve(serverName + "." + extension).normalize()。
// 作用:Path candidate = trustedRoot.resolve(serverName + "." + extension).normalize();;检查生成路径。
    if (!candidate.startsWith(trustedRoot)) throw new SecurityException("path escapes root");
// 初始状态:if (!candidate.startsWith(trustedRoot)) throw new SecurityException("path escapes root")。
    Path parent = candidate.getParent();
// 作用:Path parent = candidate.getParent();;检查生成路径。
// 初始状态:parent = candidate.getParent()。
    Path realParent = parent.toRealPath();
// 作用:Path realParent = parent.toRealPath();;检查生成路径。
// 初始状态:realParent = parent.toRealPath()。
    if (Files.isSymbolicLink(parent) || !realParent.startsWith(trustedRoot)) {
        throw new SecurityException("parent escapes root");
// 作用:throw new SecurityException("parent escapes root");;检查生成路径。
// 初始状态:throw new SecurityException("parent escapes root")。
    }
    return candidate;
}
// 输出:返回 trustedRoot 下的规范化目标
// 作用:用于把服务端生成的名称限制在受信 root 内,并拒绝符号链接父目录。

trustedRoot 构造时仍需先 toAbsolutePath().normalize()、拒绝 root 符号链接、调用 toRealPath() 并确认是目录;此处只负责生成目标的二次边界检查。

download:按文件 ID授权后返回流 ​

用途:用于只接受受控文件 ID,查询元数据并在创建响应流前完成当前主体授权。

java
StreamingResponseBody download(String fileId, String principal) throws Exception {
// 作用:StreamingResponseBody download(String fileId, String principal) throws Exception {;按文件 ID授权后返回流。
    StoredFile record = metadata.find(fileId);
// 作用:StoredFile record = metadata.find(fileId);;按文件 ID授权后返回流。
// 初始状态:record = metadata.find(fileId)。
    if (record == null) throw new SecurityException("resource denied");
// 作用:if (record == null) throw new SecurityException("resource denied");;按文件 ID授权后返回流。
// 初始状态:if (record == null) throw new SecurityException("resource denied")。
    Path file = resolveAuthorized(record, principal);
// 初始状态:file = resolveAuthorized(record, principal)。
// 作用:Path file = resolveAuthorized(record, principal);;按文件 ID授权后返回流。
    Path relative = trustedRoot.relativize(file);
// 作用:Path relative = trustedRoot.relativize(file);;按文件 ID授权后返回流。
// 初始状态:relative = trustedRoot.relativize(file)。
    if (relative.getNameCount() != 1) throw new SecurityException("resource denied");
// 初始状态:if (relative.getNameCount() != 1) throw new SecurityException("resource denied")。
    return output -> streamAuthorized(file, relative, output);
}
// 输出:返回已授权文件的 StreamingResponseBody
// 说明:download(fileId) 先以受控 ID 查询 metadata,再检查当前主体权限,最后才打开记录中的 serverName;请求参数不是磁盘路径。

下载入口不接收任意 Path;resolveAuthorized 和 streamAuthorized 是同一服务的辅助方法,分别负责真实路径/授权和安全打开。

resolveAuthorized:确认授权与真实路径 ​

用途:用于在下载前同时检查文件授权、root 边界、符号链接和普通文件类型。

java
private Path resolveAuthorized(StoredFile record, String principal) throws Exception {
// 作用:private Path resolveAuthorized(StoredFile record, String principal) throws Exception {;确认授权与真实路径。
    if (!authorization.canRead(principal, record.id())) throw new SecurityException("resource denied");
// 作用:if (!authorization.canRead(principal, record.id())) throw new SecurityException("resource denied");;确认授权与真实路径。
// 初始状态:if (!authorization.canRead(principal, record.id())) throw new SecurityException("resource denied")。
    Path normalized = record.storagePath().toAbsolutePath().normalize();
// 初始状态:normalized = record.storagePath().toAbsolutePath().normalize()。
// 作用:Path normalized = record.storagePath().toAbsolutePath().normalize();;确认授权与真实路径。
    if (!normalized.startsWith(trustedRoot)
            || !Files.exists(normalized, LinkOption.NOFOLLOW_LINKS)
            || Files.isSymbolicLink(normalized)) throw new SecurityException("resource denied");
// 作用:|| !Files.exists(normalized, LinkOption.NOFOLLOW_LINKS);确认授权与真实路径。
// 作用:|| Files.isSymbolicLink(normalized)) throw new SecurityException("resource denied");;确认授权与真实路径,返回读取结果。
// 初始状态:|| Files.isSymbolicLink(normalized)) throw new SecurityException("resource denied")。
    Path realFile = normalized.toRealPath();
// 作用:Path realFile = normalized.toRealPath();;确认授权与真实路径。
// 初始状态:realFile = normalized.toRealPath()。
    if (!realFile.startsWith(trustedRoot)
            || !Files.isRegularFile(realFile, LinkOption.NOFOLLOW_LINKS)) {
        throw new SecurityException("resource denied");
// 作用:throw new SecurityException("resource denied");;确认授权与真实路径。
// 初始状态:throw new SecurityException("resource denied")。
    }
    return realFile;
}
// 输出:返回 trustedRoot 内的真实普通文件路径
// 说明:resolveAuthorized(fileId, principal) 返回位于 storageRoot 内、授权主体可读且非符号链接的普通文件;任何一项不满足都在打开流之前拒绝。

streamAuthorized:安全打开并流式传输 ​

用途:用于优先使用 SecureDirectoryStream 的相对路径和 NOFOLLOW_LINKS 打开下载文件。

java
private void streamAuthorized(Path file, Path relative, OutputStream output) throws Exception {
// 作用:private void streamAuthorized(Path file, Path relative, OutputStream output) throws Exception {;安全打开并流式传输。
    try (DirectoryStream<Path> rootStream = Files.newDirectoryStream(trustedRoot)) {
// 初始状态:rootStream = Files.newDirectoryStream(trustedRoot))。
// 作用:try (DirectoryStream<Path> rootStream = Files.newDirectoryStream(trustedRoot)) {;安全打开并流式传输。
        if (rootStream instanceof SecureDirectoryStream<?>) {
            @SuppressWarnings("unchecked")
            SecureDirectoryStream<Path> secureRoot = (SecureDirectoryStream<Path>) rootStream;
// 初始状态:secureRoot = (SecureDirectoryStream<Path>) rootStream。
            try (var channel = secureRoot.newByteChannel(relative,
                    Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS));
// 作用:try (var channel = secureRoot.newByteChannel(relative,;安全打开并流式传输。
// 初始状态:channel = secureRoot.newByteChannel(relative,。
                 var input = Channels.newInputStream(channel)) {
// 初始状态:input = Channels.newInputStream(channel))。
                input.transferTo(output);
// 作用:input.transferTo(output);;安全打开并流式传输。
            }
        } else {
            // 只允许服务进程专属 root 的 provider 使用最终路径兜底。
            try (var input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) {
// 初始状态:input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS))。
                input.transferTo(output);
// 作用:input.transferTo(output);;安全打开并流式传输。
            }
        }
    }
}
// 输出:文件字节流式写入 HTTP 输出
// 作用:用于优先使用 `SecureDirectoryStream` 的相对路径和 `NOFOLLOW_LINKS` 打开下载文件。
// 结果:授权通过的文件以流式响应返回,资源在完成或异常时关闭。

StoredFile、FileAuthorization 和 FileMetadata 仍是 SecureFileService 的类型;跨平台 provider 不支持 SecureDirectoryStream 时,部署必须保证 root 仅服务进程可写,不能退回普通跟随链接打开。

SecureFileService 依赖类型:串起完整调用链 ​

用途:用于定义存储记录、上传/下载授权和元数据边界;实际实现应分别接入持久化与当前主体。

java
record StoredFile(String id, Path storagePath) {}
interface FileAuthorization {
    boolean canUpload(String principal);
    boolean canRead(String principal, String fileId);
}
interface FileMetadata {
    StoredFile save(String id, Path path);
    StoredFile find(String id);
}
// 输出:stored=<uuid-server-name>
// 说明:SecureFileService 依赖 FileMetadataRepository 按 fileId 查记录、FileAuthorizer 校验 principal、Path storageRoot 定位服务端文件名;这些接口需由真实 Bean 实现,直接 new 测试替身不验证容器装配。

MultipartFile.transferTo 只完成传输,不替你校验路径、覆盖策略、权限和病毒扫描结果;这里改用 getInputStream().transferTo 配合一次性的 CREATE_NEW + NOFOLLOW_LINKS 输出打开。serverName 由应用随机生成,创建时若已存在(包括符号链接)就失败,不做先 exists 再写入的竞态检查;若文件系统 provider 不支持 NOFOLLOW_LINKS,应让操作失败并修复部署,不得降级回普通 transferTo。部署时 trustedRoot 必须是服务进程专属、普通用户不可写的目录;这能缩小替换父路径的窗口,但不能声称完全消除 TOCTOU,拥有目录写权限的并发攻击者仍需要操作系统 ACL、隔离目录或更强的文件句柄策略。

在落盘前拒绝非法上传 ​

用途:用于在落盘前限制允许的后缀和字节数;校验失败就拒绝,而不是先保存再删除。

java
import java.util.Locale;
import java.util.Set;

boolean allowed(String filename, long size) {
    String lower = filename == null ? "" : filename.toLowerCase(Locale.ROOT);
// 初始状态:lower = filename == null ? "" : filename.toLowerCase(Locale.ROOT)。
// 作用:String lower = filename == null ? "" : filename.toLowerCase(Locale.ROOT);;把 filename 统一转为小写,null 按空串处理。
    int dot = lower.lastIndexOf('.');
// 初始状态:dot = lower.lastIndexOf('.')。
// 作用:int dot = lower.lastIndexOf('.');;dot 记录最后一个点的索引,无点时为 -1。
    String extension = dot >= 0 ? lower.substring(dot + 1) : "";
// 初始状态:extension = dot >= 0 ? lower.substring(dot + 1) : ""。
// 作用:String extension = dot >= 0 ? lower.substring(dot + 1) : "";;截取点后的扩展名,无点时 extension 为空串。
    boolean ok = Set.of("csv", "txt").contains(extension) && size <= 10_000_000;
// 初始状态:ok = Set.of("csv", "txt").contains(extension) && size <= 10_000_000。
// 作用:boolean ok = Set.of("csv", "txt").contains(extension) && size <= 10_000_000;;ok 为 true 表示扩展名和大小均通过校验。
    System.out.println(ok ? "accepted" : "rejected:over-size-or-extension");
// 输出:rejected:over-size-or-extension
    return ok;
}

// 作用:用于在落盘前限制允许的后缀和字节数;校验失败就拒绝,而不是先保存再删除。

大小白名单必须和反向代理、multipart 解析器的限制一致;扩展名检查应配合内容魔数和业务格式校验。空值、双扩展名、大小超过上限和未知后缀都是失败边界,不能用 Content-Type 单独放行。

路径规范化:限制存储根 ​

用途:用于把候选文件路径转换成绝对规范化路径,并确认它仍然位于受控根目录内。

java
import java.nio.file.Path;

Path storageRoot = Path.of("/srv/app-data").toAbsolutePath().normalize();
// 初始状态:storageRoot = Path.of("/srv/app-data").toAbsolutePath().normalize()。
// 作用:Path storageRoot = Path.of("/srv/app-data").toAbsolutePath().normalize();;限制存储根。
Path candidate = storageRoot.resolve("generated-7.csv").normalize();
// 初始状态:candidate = storageRoot.resolve("generated-7.csv").normalize()。
// 作用:Path candidate = storageRoot.resolve("generated-7.csv").normalize();;限制存储根。
if (!candidate.startsWith(storageRoot)) {
    throw new SecurityException("path escapes storage root");
// 初始状态:throw new SecurityException("path escapes storage root")。
// 作用:throw new SecurityException("path escapes storage root");;限制存储根。
}
System.out.println("inside=" + candidate.startsWith(storageRoot));
// 输出:inside=true
// 作用:用于把候选文件路径转换成绝对规范化路径,并确认它仍然位于受控根目录内。

normalize() 只处理字符串层面的 . 和 ..,不会检查文件是否存在,也不能单独抵御符号链接。写入前还应使用受控权限、避免跟随链接或比较真实路径;比较时不要只比较字符串前缀,例如 /srv/app-data-evil 不是 /srv/app-data 的子目录。

Content-Disposition:设置下载文件名 ​

用途:用于把受控展示名写入附件响应头,避免换行、引号或路径片段污染 HTTP 头和用户界面。

java
import java.nio.charset.StandardCharsets;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;

HttpHeaders headers = new HttpHeaders();
// 初始状态:headers = new HttpHeaders()。
// 作用:HttpHeaders headers = new HttpHeaders();;设置下载文件名。
String displayName = "report-7.csv";
// 初始状态:displayName = "report-7.csv"。
// 作用:String displayName = "report-7.csv";;设置下载文件名。
headers.setContentDisposition(
    ContentDisposition.attachment().filename(displayName, StandardCharsets.UTF_8).build());
// 作用:headers.setContentDisposition(;设置下载文件名。
// 作用:ContentDisposition.attachment().filename(displayName, StandardCharsets.UTF_8).build());;设置下载文件名。
System.out.println(headers.getFirst(HttpHeaders.CONTENT_DISPOSITION));
// 输出:attachment; filename*=UTF-8''report-7.csv
// 说明:Content-Disposition 使用 attachment 与经过 RFC 5987 编码的 filename*=UTF-8''...;展示名中的换行、引号和路径分隔符先被拒绝或规范化。

展示名要经过长度、字符和扩展名检查,不能接受包含 CR/LF 的输入;safeDisplayName 应把控制字符替换或拒绝后再交给 ContentDisposition。响应头只说明浏览器如何处置内容,不等于文件权限检查;下载前仍需按当前主体和文件 ID执行授权。

流式下载:避免一次性读入内存 ​

用途:用于把受控路径分块写到 HTTP 响应,适合大文件并能在客户端断开时及时释放流。

java
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;

StreamingResponseBody body = resourceService.download("file-7", principal);
// 初始状态:body = resourceService.download("file-7", principal)。
// 作用:StreamingResponseBody body = resourceService.download("file-7", principal);;避免一次性读入内存。
System.out.println("stream=authorized");
// 输出:stream=authorized
// 作用:用于把受控路径分块写到 HTTP 响应,适合大文件并能在客户端断开时及时释放流。

StreamingResponseBody 运行于 Spring MVC 的异步执行边界,线程池和超时需要单独配置;小文件也可以使用 InputStreamResource,但不应把未知大小的文件读成 byte[]。中途 I/O 异常表示传输失败,日志需带文件 ID 和 trace ID,不能在响应已提交后强行改成普通错误 JSON。

不常用但需要知道 ​

路径穿越:拒绝 .. 与符号链接边界 ​

用途:用于识别请求中的 ../、绝对路径和符号链接跳转,避免下载或覆盖根目录之外的文件。

java
try {
    resourceService.download(requestedFileId, principal);
// 初始状态:resourceService.download(requestedFileId, principal)。
// 作用:resourceService.download(requestedFileId, principal);;拒绝 .. 与符号链接边界。
    System.out.println("path-check=authorized-file");
// 输出:path-check=authorized-file
} catch (SecurityException denied) {
    System.out.println("path-check=resource-denied");
// 输出:path-check=resource-denied
}

// 作用:用于识别请求中的 `../`、绝对路径和符号链接跳转,避免下载或覆盖根目录之外的文件。

这里的 requestedFileId 只用于查询受控元数据,不能替代服务入口接收任意 Path;store 和 download 都在内部调用同一个受信 root/授权/真实路径边界。可提供 SecureDirectoryStream 的文件系统会用相对路径和 NOFOLLOW_LINKS 打开文件;不提供该能力的跨平台 provider 只能使用最终文件的 NOFOLLOW_LINKS,并且必须把 root ACL 限制为服务进程可写、禁止普通用户并发改名或替换目录。normalize、startsWith、toRealPath、符号链接策略和授权缺一不可,但它们不是消除 TOCTOU 的绝对保证。拒绝后只返回“资源不可用”或权限错误,不把内部路径写进响应;上传和下载都应使用同一套策略。

临时文件:失败时清理中间产物 ​

用途:用于在扫描、解压或转换前创建受控临时文件,并确保成功、失败和取消路径都清理资源。

java
import java.nio.file.Files;
import java.nio.file.Path;

void scanThenMove(Path root, byte[] data) throws Exception {
    Path temp = Files.createTempFile(root, "upload-", ".tmp");
// 初始状态:temp = Files.createTempFile(root, "upload-", ".tmp")。
// 作用:Path temp = Files.createTempFile(root, "upload-", ".tmp");;失败时清理中间产物。
    Exception failure = null;
// 初始状态:failure = null。
    try {
        Files.write(temp, data);
// 初始状态:Files.write(temp, data)。
// 作用:Files.write(temp, data);;失败时清理中间产物。
        System.out.println("scan=ok");
// 输出:scan=ok
    } catch (Exception ex) {
        failure = ex;
// 初始状态:failure = ex。
        throw ex;
    } finally {
        try {
            Files.deleteIfExists(temp);
// 初始状态:Files.deleteIfExists(temp)。
// 作用:Files.deleteIfExists(temp);;失败时清理中间产物。
        } catch (Exception cleanup) {
            if (failure != null) failure.addSuppressed(cleanup);
            else System.err.println("temp-cleanup-failed=" + cleanup.getClass().getSimpleName());
// 初始状态:failed = " + cleanup.getClass().getSimpleName())。
// 作用:else System.err.println("temp-cleanup-failed=" + cleanup.getClass().getSimpleName());;失败时清理中间产物。
        }
    }
}
// 作用:用于在扫描、解压或转换前创建受控临时文件,并确保成功、失败和取消路径都清理资源。

临时文件名由系统生成,目录权限和磁盘配额仍需配置;不要使用固定文件名或系统共享临时目录存放未加密的敏感内容。成功移动后要确认目标文件完整,清理失败应记录告警但不能覆盖原始业务失败。

拒绝路径:统一返回错误而非暴露绝对路径 ​

用途:用于把大小、类型、权限和路径失败转换成稳定错误码,避免把服务器路径、权限细节和堆栈泄露给客户端。

java
record UploadResult(boolean accepted, String code) {}

UploadResult reject(String reason) {
    String code = switch (reason) {
// 初始状态:code = switch (reason)。
        case "size" -> "FILE_TOO_LARGE";
        case "path" -> "FILE_NOT_ALLOWED";
        default -> "FILE_REJECTED";
    };
    System.out.println("code=" + code);
// 输出:code=FILE_NOT_ALLOWED
    return new UploadResult(false, code);
// 初始状态:return new UploadResult(false, code)。
// 作用:return new UploadResult(false, code);;统一返回错误而非暴露绝对路径。
}

// 作用:用于把大小、类型、权限和路径失败转换成稳定错误码,避免把服务器路径、权限细节和堆栈泄露给客户端。

内部日志可以记录受控的文件 ID、失败分类和 trace ID,不能把异常堆栈、绝对路径或原始文件名无过滤地放进响应。认证主体的权限失败和文件内容校验失败也应使用不同的内部事件,便于审计和告警。

继续阅读 ​

  • List 基础:整理允许后缀、错误列表和批量元数据时查 List 的选择。
  • Map 基础:按文件 ID、媒体类型和白名单维护元数据时查 Map 的边界。
  • String 文本处理:提取后缀、裁剪展示名和处理编码时查字符串规则。

简单案例 ​

java
import java.nio.file.Path;
import java.util.Locale;
import java.util.Set;

final class UploadPolicy {
    private final Path root;
    private final Set<String> extensions = Set.of("csv", "txt");
// 初始状态:extensions = Set.of("csv", "txt")。
// 作用:private final Set<String> extensions = Set.of("csv", "txt");;统一返回错误而非暴露绝对路径。

    UploadPolicy(Path root) {
        this.root = root.toAbsolutePath().normalize();
// 初始状态:root = root.toAbsolutePath().normalize()。
// 作用:this.root = root.toAbsolutePath().normalize();;统一返回错误而非暴露绝对路径。
    }

    String validate(String name, long size) {
        int dot = name == null ? -1 : name.lastIndexOf('.');
// 初始状态:dot = name == null ? -1 : name.lastIndexOf('.')。
// 作用:int dot = name == null ? -1 : name.lastIndexOf('.');;统一返回错误而非暴露绝对路径。
        String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);
// 初始状态:extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT)。
// 作用:String extension = dot < 0 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT);;统一返回错误而非暴露绝对路径。
        Path target = root.resolve("generated-7." + extension).normalize();
// 初始状态:target = root.resolve("generated-7." + extension).normalize()。
// 作用:Path target = root.resolve("generated-7." + extension).normalize();;统一返回错误而非暴露绝对路径。
        if (size <= 0 || size > 10_000_000 || !extensions.contains(extension)) {
            return "rejected";
        }
        if (!target.startsWith(root)) return "rejected";
        return "accepted:" + target.getFileName();
// 初始状态:return "accepted:" + target.getFileName()。
// 作用:return "accepted:" + target.getFileName();;统一返回错误而非暴露绝对路径。
    }
}

UploadPolicy policy = new UploadPolicy(Path.of("/srv/app-data"));
// 初始状态:policy = new UploadPolicy(Path.of("/srv/app-data"))。
// 作用:UploadPolicy policy = new UploadPolicy(Path.of("/srv/app-data"));;统一返回错误而非暴露绝对路径。
System.out.println(policy.validate("report.csv", 12));
// 输出:accepted:generated-7.csv
System.out.println(policy.validate("report.exe", 12));
// 输出:rejected

示例只展示策略结果;实际控制器还需执行 MultipartFile.transferTo、内容扫描、授权和下载响应。文件 ID、存储名和展示名应分离保存,任何失败都不能把临时路径当作公开资源。

易混点 ​

  • getOriginalFilename() 是客户端元数据,不能直接作为保存路径;transferTo 也不会替你做安全校验。
  • 扩展名、Content-Type 和文件魔数分别是不同证据,白名单应在写入前组合判断。
  • normalize() 能消除 ..,但不能自动处理符号链接、权限和真实路径;根目录比较必须使用 Path.startsWith 语义。
  • Content-Disposition 只控制下载处置和展示名,不等于资源授权,也不允许把原始输入写进 HTTP 头。
  • StreamingResponseBody 避免大文件堆内存,但响应提交后的 I/O 失败不能再改写成普通 JSON。
  • 临时文件必须有随机名、受控目录和 finally 清理;清理失败要告警,不能掩盖上传失败。

课后小问 ​

  1. 为什么不能直接使用 MultipartFile.getOriginalFilename() 拼接目标路径? 答案:文件名由客户端提供,可能包含路径片段、特殊字符或误导性扩展名。 解析:服务端应生成随机存储名,再对扩展名、内容和规范化路径做白名单检查;展示名单独保存。

  2. normalize() 返回的路径已经安全了吗? 答案:没有,它只处理路径片段,仍需考虑符号链接、真实路径、权限和根目录策略。 解析:对已存在路径可以比较 toRealPath(),对写入目录要限制权限或禁止跟随链接,不能只做字符串替换。

  3. 大文件下载为什么不直接 Files.readAllBytes? 答案:一次性读取会按文件大小占用堆内存,容易造成高并发下的内存压力。 解析:流式响应分块传输并关闭输入流;响应中途失败要记录传输失败状态,而不是假装下载成功。

本节小结 ​

  • 上传先限制大小和类型,再使用服务器生成的存储名与受控根目录落盘。
  • transferTo 只是传输动作,不能替代白名单、路径规范化、内容检测和权限控制。
  • 下载路径必须经过根目录检查,文件名要安全编码,响应大文件优先使用流式接口。
  • normalize、真实路径和符号链接是不同安全层,临时文件还要在所有分支清理。
  • 客户端只接收稳定错误码,不应看到服务器绝对路径、堆栈或未经脱敏的原始文件名。

快速回顾 ​

  • 能从 MultipartFile 读取元数据并在 transferTo 前执行拒绝规则。
  • 会用 Path.normalize() 与根目录 startsWith 检查基本路径边界。
  • 能设置安全的 Content-Disposition,并选择 StreamingResponseBody 传输大文件。
  • 能说明扩展名、大小、魔数、符号链接和临时文件清理各自负责什么。