背景文件上传是 Web 系统里很常见的功能,头像、附件、合同、工单图片、导入模板都会涉及。它的问题在于看起来业务简单,实际跨了权限、存储、解析、访问控制、内容安全等多个边界。很多漏洞并不是单点过滤缺失,而是多个细节叠加后形成风险。这篇文章整理一次代码审计中比较典型的文件上传风险分析方法,重点放在如何从代码层面识别问题、如何做安全验证,以及如何给出可落地的修复建议。内容只讨论授权测试和防护思路,不涉及非法利用。问题场景审计目标是一个 Java Web 系统,后端使用 Spring MVC,上传文件通过统一接口处理。业务侧声称已经做了白名单校验,上传目录也不在 Web 根目录下。但在审计过程中发现,部分接口复用了上传服务,却传入了不同的参数,导致实际防护并不一致。简化后的调用关系大致如下:Controller
-> UploadService.upload(file, bizType, userId)
-> FileNameUtil.normalize(originalName)
-> FileTypeValidator.check(file, allowTypes)
-> StorageClient.save(path, fileBytes)
-> AttachmentMapper.insert(metadata)表面上流程完整,但真正需要关注的是:文件名如何生成、文件类型如何判断、存储路径是否可控、下载/预览接口是否会触发解析,以及上传后的文件是否能被越权访问。技术分析1. 只看扩展名是不够的最常见的逻辑是取原始文件名后缀,然后和白名单比较:String ext = originalName.substring(originalName.lastIndexOf('.') + 1);
if (!allowExts.contains(ext.toLowerCase())) {
throw new BizException(\"file type not allowed\");
}这种写法的问题不只在于绕过大小写,更大的问题是它把“文件类型”等同于“扩展名”。扩展名只能作为展示或粗略分类依据,不能作为唯一安全判断。审计时建议同时检查三点:是否对原始文件名做了规范化处理,避免空字节、控制字符、路径分隔符等异常字符进入后续流程。是否使用内容特征进行校验,例如文件头、MIME 探测、图片解码验证等。是否存在不同业务接口传入不同白名单,导致某些入口放宽限制。对于图片类上传,比较稳妥的做法不是只判断后缀,而是尝试用安全库解码并重编码,丢弃原始载荷中的非图片结构。BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
throw new BizException(\"invalid image\");
}
// 重新编码,避免保留异常附加内容
ImageIO.write(image, \"png\", targetFile);注意:ImageIO 本身也要关注 JDK 版本和历史漏洞,不建议把“不报错”视为绝对安全,只能作为多层校验的一部分。2. 文件名与存储路径要完全脱离用户输入审计上传功能时,我通常会先搜索这些关键点:getOriginalFilename
transferTo
MultipartFile
FileOutputStream
Paths.get
new File
uploadDir
download重点看原始文件名有没有直接拼接到服务端路径里。风险代码常见形式如下:String filename = file.getOriginalFilename();
File target = new File(uploadDir + \"/\" + filename);
file.transferTo(target);这类写法容易引入路径穿越、文件覆盖、特殊文件名兼容性问题。更推荐的方式是服务端生成随机文件名,并将原始文件名仅作为 metadata 存数据库:String safeExt = FileTypeValidator.getSafeExt(file);
String storedName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".\" + safeExt;
Path base = Paths.get(uploadRoot).toAbsolutePath().normalize();
Path target = base.resolve(storedName).normalize();
if (!target.startsWith(base)) {
throw new SecurityException(\"invalid path\");
}
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);这里有两个细节比较重要:一是路径 normalize 后要判断是否仍在基准目录下;二是不要使用用户原始文件名作为落地文件名。即使已经过滤了 ../,在不同系统、不同编码、反斜杠、全角字符等场景下仍然容易出问题。3. 上传目录不在 Web 根目录,不代表没有风险很多团队会把文件保存到独立目录,然后认为风险已经隔离。实际还要看文件访问链路。如果下载接口存在如下逻辑,仍然可能引出问题:@GetMapping(\"/file/preview\")
public void preview(String id, HttpServletResponse response) {
Attachment att = attachmentMapper.selectById(id);
response.setContentType(att.getContentType());
Files.copy(Paths.get(att.getPath()), response.getOutputStream());
}这里需要关注:id 是否可枚举,是否校验当前用户对该附件的访问权限。Content-Type 是否来自用户上传时提交的数据,是否可信。Content-Disposition 是否设置为 attachment,避免浏览器直接执行或渲染高风险内容。是否设置 X-Content-Type-Options: nosniff。预览接口是否支持 Office、PDF、SVG、HTML 等复杂格式,是否走了安全沙箱或转换服务。比较稳妥的下载响应示例:response.setHeader(\"X-Content-Type-Options\", \"nosniff\");
response.setHeader(\"Content-Disposition\", \"attachment; filename=\\\"download.bin\\\"\");
response.setContentType(\"application/octet-stream\");如果业务必须支持在线预览,应当按类型拆分处理逻辑,而不是所有文件都直接流式返回给浏览器。4. 元数据权限经常被忽略上传后的文件通常会在附件表里记录信息,例如:id | biz_id | biz_type | owner_id | path | original_name | content_type | created_at权限问题常见于下载接口只根据附件 id 查询文件,而没有关联业务对象判断当前用户是否有权限访问。安全审计时不能只测上传成功,还要测“上传后谁能访问”。建议的权限模型是:附件本身不单独决定授权,而是绑定业务对象,由业务对象权限决定附件访问权限。例如工单附件必须校验当前用户是否能访问该工单,项目附件必须校验项目成员关系。Attachment att = attachmentMapper.selectById(fileId);
Ticket ticket = ticketMapper.selectById(att.getBizId());
if (!permissionService.canViewTicket(currentUser, ticket)) {
throw new AccessDeniedException(\"no permission\");
}关键步骤:审计与验证清单实际审计时,可以按下面顺序走,效率比较高。1. 找入口搜索 MultipartFile、CommonsMultipartFile、request.getPart。查看是否存在多个上传接口,例如头像、富文本、附件、导入文件。确认是否有统一上传服务,还是各业务模块各自实现。2. 看校验扩展名白名单是否大小写统一处理。是否校验文件头、MIME、实际内容。图片是否解码重编码。压缩包是否限制文件数量、总解压大小、目录层级。是否限制单文件大小和用户维度总容量。3. 看落地文件名是否由服务端生成。路径是否使用固定基准目录并做 normalize 校验。是否可能覆盖已有文件。是否保存到对象存储,Bucket 权限是否公开。上传目录是否具备执行权限,Web Server 是否会解析其中内容。4. 看访问下载接口是否校验业务权限。文件 id 是否可枚举。Content-Type 和 Content-Disposition 是否安全。是否允许直接访问存储路径。临时链接是否有过期时间和签名校验。5. 看日志与告警是否记录上传人、业务对象、文件哈希、大小、类型。是否对异常类型、超大文件、重复失败进行告警。是否能快速定位并下线某个已上传文件。配置层面的加固建议如果使用 Nginx 提供静态文件访问,建议至少避免上传目录被执行或被浏览器误解析。示例配置如下:location /uploads/ {
alias /data/app/uploads/;
autoindex off;
add_header X-Content-Type-Options nosniff;
add_header Content-Disposition \"attachment\";
types { }
default_type application/octet-stream;
}如果上传目录不需要被 Nginx 直接访问,更建议完全不暴露静态路径,统一通过后端带权限校验的下载接口输出。Spring Boot 侧可以设置基础大小限制,避免资源被轻易打满:spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=20MB但这只是入口限制,仍应在业务层做用户维度、租户维度、文件类型维度的限制。容易踩坑的点只修一个上传接口,忽略富文本编辑器、导入功能、移动端接口。白名单写在前端,后端只做弱校验。认为对象存储默认安全,但 Bucket 或 CDN 规则配置成了公开读。下载接口没有权限校验,导致附件越权。保留用户提交的 Content-Type,并原样返回。允许 SVG、HTML、PDF 在线预览,但没有隔离域名和响应头策略。压缩包上传后自动解压,未处理 Zip Slip 和解压炸弹问题。修复优先级建议如果系统已经上线,建议按风险优先级分批处理:第一优先级:关闭上传目录脚本执行能力,禁止直接解析和执行上传内容。第二优先级:修复下载/预览接口越权,补齐业务权限校验。第三优先级:改造文件名生成和路径拼接逻辑,避免用户输入影响落地路径。第四优先级:完善类型校验、图片重编码、压缩包安全处理。第五优先级:补齐日志、告警、文件下线和追溯能力。总结文件上传风险很少是一个 if 判断能解决的。比较可靠的思路是把它拆成几个边界:入口校验、服务端命名、存储隔离、访问授权、响应安全、日志追溯。每个边界都做基本防护,整体风险才会明显下降。从代码审计角度看,不要只盯着“能不能上传某种后缀”,更要关注上传后的文件如何被保存、如何被访问、谁能访问、浏览器会如何处理。很多真实问题都出在这些连接处。
推荐意见