文件上传下载与资源安全
学习目标
- 能从
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 请求边界读取大小、原始名和输入流,并在真正写入前完成基本拒绝判断。
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 写入应用生成的目标路径,避免直接信任用户提供的文件名。
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 的便利落盘方法。
执行上传白名单校验
用途:用于把客户端原始文件名转换为受控后缀,并在落盘前统一执行字节数限制。
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:写入记录失败时清理
用途:用于保证文件写成功但元数据落库失败时删除已写文件,同时保留原始元数据异常。
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 内,并拒绝符号链接父目录。
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,查询元数据并在创建响应流前完成当前主体授权。
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 边界、符号链接和普通文件类型。
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 打开下载文件。
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 依赖类型:串起完整调用链
用途:用于定义存储记录、上传/下载授权和元数据边界;实际实现应分别接入持久化与当前主体。
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、隔离目录或更强的文件句柄策略。
在落盘前拒绝非法上传
用途:用于在落盘前限制允许的后缀和字节数;校验失败就拒绝,而不是先保存再删除。
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 单独放行。
路径规范化:限制存储根
用途:用于把候选文件路径转换成绝对规范化路径,并确认它仍然位于受控根目录内。
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 头和用户界面。
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 响应,适合大文件并能在客户端断开时及时释放流。
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。
不常用但需要知道
路径穿越:拒绝 .. 与符号链接边界
用途:用于识别请求中的 ../、绝对路径和符号链接跳转,避免下载或覆盖根目录之外的文件。
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 的绝对保证。拒绝后只返回“资源不可用”或权限错误,不把内部路径写进响应;上传和下载都应使用同一套策略。
临时文件:失败时清理中间产物
用途:用于在扫描、解压或转换前创建受控临时文件,并确保成功、失败和取消路径都清理资源。
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());;失败时清理中间产物。
}
}
}
// 作用:用于在扫描、解压或转换前创建受控临时文件,并确保成功、失败和取消路径都清理资源。临时文件名由系统生成,目录权限和磁盘配额仍需配置;不要使用固定文件名或系统共享临时目录存放未加密的敏感内容。成功移动后要确认目标文件完整,清理失败应记录告警但不能覆盖原始业务失败。
拒绝路径:统一返回错误而非暴露绝对路径
用途:用于把大小、类型、权限和路径失败转换成稳定错误码,避免把服务器路径、权限细节和堆栈泄露给客户端。
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 文本处理:提取后缀、裁剪展示名和处理编码时查字符串规则。
简单案例
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 清理;清理失败要告警,不能掩盖上传失败。
课后小问
为什么不能直接使用
MultipartFile.getOriginalFilename()拼接目标路径? 答案:文件名由客户端提供,可能包含路径片段、特殊字符或误导性扩展名。 解析:服务端应生成随机存储名,再对扩展名、内容和规范化路径做白名单检查;展示名单独保存。normalize()返回的路径已经安全了吗? 答案:没有,它只处理路径片段,仍需考虑符号链接、真实路径、权限和根目录策略。 解析:对已存在路径可以比较toRealPath(),对写入目录要限制权限或禁止跟随链接,不能只做字符串替换。大文件下载为什么不直接
Files.readAllBytes? 答案:一次性读取会按文件大小占用堆内存,容易造成高并发下的内存压力。 解析:流式响应分块传输并关闭输入流;响应中途失败要记录传输失败状态,而不是假装下载成功。
本节小结
- 上传先限制大小和类型,再使用服务器生成的存储名与受控根目录落盘。
transferTo只是传输动作,不能替代白名单、路径规范化、内容检测和权限控制。- 下载路径必须经过根目录检查,文件名要安全编码,响应大文件优先使用流式接口。
normalize、真实路径和符号链接是不同安全层,临时文件还要在所有分支清理。- 客户端只接收稳定错误码,不应看到服务器绝对路径、堆栈或未经脱敏的原始文件名。
快速回顾
- 能从
MultipartFile读取元数据并在transferTo前执行拒绝规则。 - 会用
Path.normalize()与根目录startsWith检查基本路径边界。 - 能设置安全的
Content-Disposition,并选择StreamingResponseBody传输大文件。 - 能说明扩展名、大小、魔数、符号链接和临时文件清理各自负责什么。
阿源的知识库