背景
文件上传一直是 Web 系统里比较容易被低估的功能点。很多业务侧认为“限制一下后缀名”就够了,但在实际代码审计和应急排查中,上传链路往往牵涉到网关、应用框架、对象存储、静态资源服务、异步处理任务等多个环节。任何一个环节处理不严谨,都可能导致越权访问、任意文件覆盖、敏感信息泄露,甚至在特定部署条件下造成服务端代码执行风险。
这篇文章整理一次 Java Web 项目文件上传问题的审计思路。内容偏方法论和落地检查,不提供非法攻击操作,也不讨论绕过生产系统防护的细节。适合做代码审计、安全自查和修复验证时参考。
问题场景
某内部业务系统提供附件上传能力,常见入口包括头像、工单附件、富文本图片、导入模板等。系统采用 Spring Boot,上传后的文件会落到本地目录,再由 Nginx 映射为静态资源访问。审计目标是确认上传流程是否存在以下风险:
- 上传类型校验不完整,仅依赖文件名后缀。
- 文件保存路径可控,存在目录穿越或覆盖风险。
- 上传目录与可执行目录重叠,存在脚本文件被解析风险。
- 文件访问权限设计不当,导致未授权访问附件。
- 异常处理和日志记录暴露服务端路径、对象存储地址或临时凭证。
入口定位
审计文件上传,第一步不是直接看某个 Controller,而是尽量把上传链路梳理完整。常用的检索关键词包括:
MultipartFile
CommonsMultipartFile
@RequestParam("file")
@RequestPart
transferTo(
getOriginalFilename()
Files.copy(
FileOutputStream
upload
attachment
avatar
import在 Spring Boot 项目里,常见的上传入口类似这样:
@PostMapping("/upload")
public Result<String> upload(@RequestParam("file") MultipartFile file) {
String url = fileService.upload(file);
return Result.ok(url);
}真正需要关注的是 fileService.upload 内部如何处理文件名、路径、类型、大小、权限和返回 URL。
技术分析
1. 文件名处理
一个常见问题是直接使用 getOriginalFilename() 作为服务端保存文件名。这个值来自客户端请求,不应直接信任。风险点包括路径分隔符、特殊字符、超长文件名、同名覆盖,以及不同系统对文件名解析规则不一致。
不建议的写法:
String filename = file.getOriginalFilename();
File dest = new File(uploadDir + "/" + filename);
file.transferTo(dest);更稳妥的做法是:服务端生成随机文件名,只保留经过白名单校验的扩展名;原始文件名如需展示,应单独作为元数据保存,并进行长度和字符集限制。
String originalName = file.getOriginalFilename();
String ext = FileNameUtil.safeExtension(originalName);
String savedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(savedName).normalize();这里需要注意,normalize() 只能消除路径中的冗余片段,不能替代根目录校验。必须确认最终路径仍在预期上传目录内。
if (!target.startsWith(uploadRoot)) {
throw new SecurityException("invalid upload path");
}2. 类型校验
仅通过后缀判断文件类型是不够的。实际项目中建议采用多层校验:
- 业务允许的扩展名白名单,例如
jpg、png、pdf、docx。 - MIME 类型作为辅助判断,但不能单独依赖,因为请求头可伪造。
- 文件魔数或格式解析校验,例如图片可尝试读取宽高,PDF 可检查文件头和解析结构。
- 对高风险类型明确拒绝,例如服务端脚本、可执行文件、压缩包内嵌复杂结构等。
示例校验逻辑可以这样组织:
private static final Set<String> ALLOW_EXT = Set.of("jpg", "jpeg", "png", "pdf");
public void validate(MultipartFile file, String ext) throws IOException {
if (file.isEmpty()) {
throw new IllegalArgumentException("empty file");
}
if (file.getSize() > 10 * 1024 * 1024) {
throw new IllegalArgumentException("file too large");
}
if (!ALLOW_EXT.contains(ext.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("unsupported file type");
}
try (InputStream in = file.getInputStream()) {
byte[] header = in.readNBytes(8);
if (!FileTypeDetector.match(ext, header)) {
throw new IllegalArgumentException("file content mismatch");
}
}
}这里的重点不是某个检测函数写得多复杂,而是不要把“后缀名正确”当成“文件安全”。对于图片类上传,还可以在服务端重新编码,去除异常数据段和潜在的嵌入内容。
3. 保存目录与静态解析
上传目录的部署方式很关键。如果上传目录被 Web 容器当作可执行目录处理,风险会明显上升。比较推荐的做法是:
- 上传文件存储在应用运行目录之外。
- 静态资源服务只按二进制文件返回,不做脚本解析。
- 通过单独域名或路径提供附件访问,降低 Cookie、同源策略相关风险。
- 对私有附件不要直接暴露静态 URL,应通过鉴权接口读取。
Nginx 侧可以显式关闭脚本解析能力,并设置安全响应头。示例仅供防护思路参考:
location /files/ {
alias /data/app-upload/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition "attachment";
}如果业务需要图片在线预览,可以按具体类型设置响应头,但不要让服务端根据用户传入参数任意控制 Content-Type。
4. 权限校验
文件上传往往不仅是“能不能传”的问题,还包括“谁能看、谁能删、谁能覆盖”。审计时建议把附件表结构也看一遍,确认是否记录了归属关系。
| 字段 | 建议 |
|---|---|
| id | 使用随机 ID 或不可预测标识,避免简单自增 ID 被遍历。 |
| owner_id | 记录上传用户或业务主体。 |
| biz_type | 区分头像、工单、合同、导入文件等场景。 |
| storage_key | 存储内部路径,不直接使用客户端文件名。 |
| original_name | 仅用于展示,输出时需要 HTML 转义。 |
| visibility | 区分公开、登录可见、仅归属人可见。 |
下载接口中应基于业务对象做权限判断,而不是只判断用户是否登录。
@GetMapping("/attachments/{id}")
public void download(@PathVariable Long id, HttpServletResponse response) {
Attachment att = attachmentService.getById(id);
permissionService.checkReadable(currentUser(), att);
fileStorage.writeToResponse(att.getStorageKey(), response);
}5. 覆盖与竞态问题
如果服务端保存文件时允许同名覆盖,就可能出现业务数据被替换的问题。即使没有代码执行风险,任意覆盖合同、头像、导入模板等文件也会造成实际影响。
建议保存时使用不可预测文件名,并使用原子创建语义,避免覆盖已有文件:
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.COPY_ATTRIBUTES);
}如果希望确保不覆盖,可以避免使用 REPLACE_EXISTING,并在数据库层对 storage_key 加唯一约束。
关键审计步骤
实际审计时,我一般按下面顺序检查,效率会比较高:
- 确认所有上传入口:Controller、富文本组件、导入接口、第三方回调、移动端接口。
- 确认统一上传服务:是否所有入口都走同一套校验逻辑,是否存在历史遗留接口。
- 检查文件名处理:是否直接使用原始文件名,是否存在路径拼接。
- 检查路径边界:最终保存路径是否限制在指定目录内。
- 检查类型校验:是否为白名单,是否结合内容识别。
- 检查大小限制:应用层、网关层、Nginx 层是否一致。
- 检查访问控制:下载、预览、删除接口是否绑定业务权限。
- 检查静态资源配置:上传目录是否可能被解释执行,是否允许目录浏览。
- 检查日志与异常:是否泄露绝对路径、对象存储内部地址、临时 token。
- 检查清理机制:失败文件、临时文件、孤儿附件是否定期清理。
修复建议
修复时不建议只在某个 Controller 上补一段后缀判断。更可维护的方式是抽象统一上传组件,让所有业务入口复用同一套安全策略。
public class UploadPolicy {
private Set<String> allowedExtensions;
private long maxSize;
private boolean requireAuth;
private boolean forceDownload;
}
public interface FileStorage {
StoredFile save(MultipartFile file, UploadPolicy policy);
void writeToResponse(String storageKey, HttpServletResponse response);
}这样后续新增上传场景时,只需要声明策略,而不是每个业务开发重复实现一遍上传逻辑。安全侧也更容易做统一测试。
验证方法
修复完成后,需要做回归验证。这里更建议用自动化测试覆盖关键规则,而不是只靠手工点页面。
@Test
void shouldRejectUnsupportedExtension() {
MockMultipartFile file = new MockMultipartFile(
"file", "test.exe", "application/octet-stream", new byte[]{0x4d, 0x5a}
);
assertThrows(IllegalArgumentException.class, () -> uploadService.upload(file));
}
@Test
void shouldGenerateServerSideFilename() {
MockMultipartFile file = new MockMultipartFile(
"file", "avatar.png", "image/png", pngBytes()
);
StoredFile stored = uploadService.upload(file);
assertFalse(stored.getStorageKey().contains("avatar.png"));
}如果有 Nginx 或对象存储配置,也建议把配置纳入发布检查清单。很多上传问题不是 Java 代码单点造成的,而是“代码允许上传 + 静态服务错误解析 + 权限控制缺失”组合出来的。
注意点
- 不要信任客户端传入的文件名、MIME、路径、业务 ID。
- 不要把上传目录放在应用可执行路径下。
- 不要把公开访问 URL 当成权限控制手段。
- 不要在异常信息里返回服务端绝对路径。
- 不要为了兼容业务无限放开扩展名,特别是压缩包和可执行类文件。
- 不要忽略旧接口,历史上传入口经常绕开新封装的安全组件。
总结
文件上传漏洞的本质不是某个后缀是否危险,而是服务端是否把“不可信输入”当成了可信文件、可信路径和可信资源。一次完整的审计需要同时覆盖代码逻辑、存储策略、访问权限和部署配置。
比较稳妥的落地原则是:服务端生成文件名、白名单校验类型、限制保存目录、上传目录不可执行、访问必须鉴权、异常信息最小化。只要这些基础规则执行到位,大多数上传相关风险都能在设计层面被压住。
推荐意见