背景
文件上传是 Web 系统里很常见的功能,头像、附件、导入模板、富文本图片都会用到。也正因为链路长、组件多,它经常成为代码审计里的高频风险点。这里整理一套偏实战的审计思路,重点不是“如何打”,而是如何在研发和安全评审阶段把风险收敛掉。
文章基于常见 Java Web 项目场景:Spring Boot、Nginx 反向代理、对象存储或本地磁盘存储。不同项目实现会有差异,但审计方法基本通用。
典型问题场景
我在项目审计中比较常见的上传问题主要有几类:
- 只校验前端后缀,后端未做强校验。
- 服务端信任 Content-Type 或原始文件名。
- 上传目录位于 Web 可访问目录,且允许脚本类文件被解析。
- 文件名未重命名,存在覆盖、路径穿越或特殊字符问题。
- 图片处理、压缩包解压、文档解析等二次处理环节缺少安全边界。
- 临时文件、失败文件未清理,导致磁盘占满或敏感文件残留。
这些问题单独看可能不严重,但组合起来往往会变成高危链路。例如:后端仅校验扩展名,上传目录又在静态资源路径下,Nginx 或容器配置不当时,就可能扩大影响面。
审计入口:先看上传链路而不是单点代码
文件上传审计不要只盯一个 Controller 方法。建议按链路拆开看:
- 入口层:Controller、接口鉴权、参数来源、文件大小限制。
- 校验层:后缀、MIME、魔数、文件名、文件内容特征。
- 存储层:本地磁盘、对象存储、数据库、临时目录。
- 访问层:下载接口、静态资源映射、Nginx 配置、CDN 配置。
- 处理层:图片裁剪、文档转换、压缩包解压、异步任务。
- 清理层:临时文件删除、失败回滚、日志脱敏。
真实项目里,很多漏洞并不是某一行代码“明显写错”,而是上传、存储、访问三个环节的假设不一致。
关键风险点分析
1. 后缀校验不能依赖原始文件名
常见写法是直接从 originalFilename 里取后缀,然后判断是否在白名单内:
String filename = file.getOriginalFilename();
String suffix = filename.substring(filename.lastIndexOf(".") + 1);
if (!allowList.contains(suffix.toLowerCase())) {
throw new BizException("unsupported file type");
}这段代码至少有几个问题:
- originalFilename 由客户端提供,不可信。
- 未处理空文件名、无后缀、多后缀、特殊 Unicode 字符。
- 仅依赖后缀,无法识别伪造内容。
- 未限制文件名长度,可能影响日志、数据库字段或文件系统。
更稳妥的做法是:原始文件名只用于展示,存储文件名由服务端生成;类型判断至少结合扩展名、魔数和业务允许范围。
String originalName = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
Set<String> allowExt = Set.of("jpg", "jpeg", "png", "pdf");
if (!allowExt.contains(ext)) {
throw new BizException("unsupported extension");
}
String storageName = UUID.randomUUID() + "." + ext;注意,cleanPath 只能做路径规范化辅助,不能把它当成完整安全措施。服务端仍应禁止使用客户端文件名作为实际存储路径。
2. Content-Type 只能作为参考
很多接口会这样判断:
if (!file.getContentType().startsWith("image/")) {
throw new BizException("only image allowed");
}Content-Type 同样来自请求头,不能作为唯一依据。比较合理的做法是把它作为辅助信息,同时读取文件头进行判断。比如图片类文件可以检查魔数:
private boolean isPng(byte[] header) {
byte[] png = new byte[] {(byte)0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
if (header.length < png.length) return false;
for (int i = 0; i < png.length; i++) {
if (header[i] != png[i]) return false;
}
return true;
}生产环境可以使用 Apache Tika 等库做类型识别,但也要注意:类型识别不是杀毒引擎,不能解决所有恶意样本问题。它的价值在于减少明显伪造和误上传。
3. 上传目录不要放在可执行路径下
这点非常关键。上传文件建议放到 Web 根目录之外,通过下载接口或对象存储授权访问。不要把用户上传文件直接放在类似下面的目录:
/usr/local/tomcat/webapps/ROOT/upload/
/var/www/html/upload/
src/main/resources/static/upload/更推荐:
/data/app/uploads/tenant-a/2025/01/再由后端下载接口读取并返回,返回时显式设置 Content-Disposition 和安全响应头:
Content-Disposition: attachment; filename="safe-name.pdf"
X-Content-Type-Options: nosniff
Content-Security-Policy: default-src 'none'; sandbox如果业务必须支持图片在线预览,也建议单独域名、单独桶、最小权限、禁止脚本解析,并配置合理的 CSP。
4. Nginx 和容器配置要一起审
上传安全经常败在部署配置。即使代码做了限制,如果 Nginx 把上传目录映射成静态资源,且某些后缀被错误转发给后端解析,也会留下隐患。
一个偏保守的静态上传目录配置示例:
location /user-content/ {
alias /data/app/uploads/;
autoindex off;
default_type application/octet-stream;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
types { }
try_files $uri =404;
}如果线上使用对象存储,也要检查桶策略:是否公开写、是否允许覆盖、是否开启版本控制、是否有生命周期清理、是否有跨租户路径隔离。
5. 压缩包解压要防路径穿越和资源消耗
很多系统有“批量导入素材”“上传压缩包自动解压”的功能,这是高风险点。审计时重点看两个问题:
- Zip Slip:压缩包内文件名包含 ../ 导致写出目标目录。
- 解压炸弹:极小压缩包解出超大文件或大量文件,耗尽磁盘和 CPU。
安全解压时应做路径归一化校验:
Path destDir = Paths.get("/data/app/imports/job-123").toRealPath();
Path target = destDir.resolve(entry.getName()).normalize();
if (!target.startsWith(destDir)) {
throw new SecurityException("invalid zip entry path");
}同时限制:
- 压缩包总大小。
- 解压后总大小。
- 文件数量。
- 单文件大小。
- 目录层级。
- 允许的文件类型。
这些限制比单纯判断后缀更有价值,因为它们能有效降低资源消耗类风险。
6. 图片处理库也需要边界
图片上传不是天然安全。图片解析、缩略图生成、EXIF 读取都依赖第三方库。审计时建议确认:
- 图片处理库版本是否存在已知漏洞。
- 是否限制图片像素宽高,而不只是限制文件大小。
- 是否清理 EXIF,避免泄露设备、GPS 等信息。
- 是否对异常图片解析失败做了兜底,不把堆栈直接返回给前端。
例如,一个 5MB 的图片可能拥有非常大的像素尺寸,处理时占用大量内存。建议在业务层增加像素限制:
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new BizException("invalid image");
}
int maxWidth = 8000;
int maxHeight = 8000;
if (image.getWidth() > maxWidth || image.getHeight() > maxHeight) {
throw new BizException("image dimension too large");
}推荐的安全实现思路
一个相对稳妥的上传流程可以参考下面的顺序:
- 接口鉴权:确认用户身份和上传权限。
- 大小限制:网关、应用、业务层都要有上限。
- 文件名处理:原始文件名仅展示,存储名服务端生成。
- 类型校验:扩展名白名单、魔数校验、必要时使用 Tika。
- 内容处理:图片重编码、文档转换、压缩包安全解压。
- 隔离存储:Web 根目录外或对象存储独立桶。
- 访问控制:下载接口鉴权,避免越权读取。
- 安全响应头:nosniff、CSP、attachment。
- 审计日志:记录上传人、文件哈希、大小、类型、存储路径。
- 清理机制:临时文件、失败任务、过期文件定期清理。
文件哈希建议记录 SHA-256,方便后续排查和去重:
MessageDigest digest = MessageDigest.getInstance("SHA-256");
try (InputStream in = file.getInputStream()) {
byte[] buffer = new byte[8192];
int len;
while ((len = in.read(buffer)) != -1) {
digest.update(buffer, 0, len);
}
}
String sha256 = HexFormat.of().formatHex(digest.digest());审计检查清单
| 检查项 | 建议标准 |
|---|---|
| 上传接口是否鉴权 | 必须鉴权,并校验业务权限 |
| 是否限制文件大小 | 网关、应用、业务层均应限制 |
| 是否信任原始文件名 | 不能用于实际存储路径 |
| 是否只校验 Content-Type | 不能作为唯一判断依据 |
| 上传目录是否可执行 | 应放在 Web 根目录外,禁止脚本解析 |
| 下载是否鉴权 | 按用户、租户、资源归属校验 |
| 压缩包是否安全解压 | 校验路径、数量、大小、层级 |
| 图片是否限制像素 | 限制宽高和内存消耗 |
| 是否记录审计日志 | 记录上传人、哈希、大小、类型 |
| 是否有清理机制 | 临时文件和过期文件应定期清理 |
常见误区
- 误区一:认为后缀白名单足够。实际还要看存储位置、访问方式和解析行为。
- 误区二:认为对象存储就一定安全。桶策略、路径隔离、临时授权同样会出问题。
- 误区三:只限制文件大小,不限制图片像素、解压后大小和文件数量。
- 误区四:把上传安全全部交给前端。前端校验只能改善体验,不能作为安全边界。
- 误区五:下载接口只按文件 ID 查询,不校验资源归属,容易产生越权读取。
总结
文件上传安全的核心不是某一个校验函数,而是整条链路的边界控制。比较可靠的原则是:客户端输入不可信、上传内容不直接执行、存储路径不暴露、访问必须鉴权、处理过程有资源上限。
做代码审计时,建议把上传功能当成一个小型系统来看:入口、校验、存储、访问、处理、清理逐项过一遍。多数风险都能在这个过程中提前发现并修掉,比上线后再靠应急排查要省很多成本。
推荐意见