跳转到帖子

背景

文件上传是 Web 系统里很常见的功能,但也是长期高发的风险点。实际项目里,很多上传模块一开始只是为了满足业务:头像、附件、合同、日志包、工单截图等,后面随着功能迭代,上传类型越来越多,校验逻辑散落在不同接口里,最终容易形成安全边界不清的问题。

这篇文章整理一次上传模块安全排查的思路,不讨论真实站点攻击过程,也不提供绕过手法,重点放在防守侧如何识别风险、如何验证修复是否有效,以及代码审计时应该关注哪些细节。

问题场景

排查对象是一个典型的 Java Web 系统,提供多个上传入口:

  • 用户头像上传
  • 后台内容管理附件上传
  • 工单系统截图上传
  • 批量导入时的表格文件上传

最初的安全要求很简单:只允许上传图片、文档和表格文件,不允许上传可执行脚本,不允许上传后被服务端解析执行,不允许通过上传文件影响其他用户数据。

但在审计时发现,系统存在几个比较典型的问题:

  • 不同上传接口各自实现校验逻辑,规则不一致
  • 部分接口只校验文件后缀,没有校验实际内容类型
  • 上传文件存储在 Web 可访问目录下
  • 文件名直接使用用户提交的原始名称
  • 部分预览功能会读取上传文件内容并返回给前端,缺少输出控制

技术分析

上传风险不只是“能不能传某种后缀”的问题,更关键的是上传后的文件会被如何处理。一般需要从三个阶段看:

  • 上传前:请求参数、文件名、大小、类型是否被校验
  • 上传中:文件保存路径、文件名生成、权限控制是否安全
  • 上传后:是否可访问、是否会被解析、是否参与后续业务处理

在代码审计中,建议先全局搜索常见上传处理点,例如 Java 项目里的 MultipartFile、transferTo、getOriginalFilename,PHP 项目里的 $_FILES,Node 项目里的 multer 或 busboy。重点不是看某个单点,而是梳理所有入口是否走了统一的上传服务。

grep -R "MultipartFile\|transferTo\|getOriginalFilename" ./src/main/java

如果发现多个 Controller 里都在直接处理上传,通常意味着规则分散,后续修复成本会比较高。更推荐把上传校验、重命名、存储、访问控制集中到统一服务中。

关键检查点

1. 文件后缀不能作为唯一判断依据

很多历史代码只做类似下面的判断:

String filename = file.getOriginalFilename();
if (!filename.endsWith(".jpg") && !filename.endsWith(".png")) {
    throw new IllegalArgumentException("file type not allowed");
}

这种判断至少有几个问题:大小写处理不一致、复杂文件名处理不严谨、只关注名称不关注内容。更稳妥的做法是使用白名单后缀配合内容识别,且以后端实际解析结果为准。

private static final Set<String> ALLOWED_EXT = Set.of("jpg", "jpeg", "png", "pdf", "xlsx");

String ext = FilenameUtils.getExtension(originalName).toLowerCase(Locale.ROOT);
if (!ALLOWED_EXT.contains(ext)) {
    throw new IllegalArgumentException("unsupported file extension");
}

内容类型可以通过文件头识别库辅助判断,例如 Apache Tika。这里的目标不是“绝对识别所有格式”,而是避免明显不匹配的文件进入业务流程。

Tika tika = new Tika();
String detectedType = tika.detect(file.getInputStream());
if (!allowedMimeTypes.contains(detectedType)) {
    throw new IllegalArgumentException("unsupported file content type");
}

2. 不要信任客户端传来的 Content-Type

请求头里的 Content-Type 和前端传来的文件类型都只能作为参考,不能作为安全判断依据。审计时可以重点看代码是否直接使用了:

  • file.getContentType()
  • 前端传入的 type 字段
  • 文件名后缀映射出的 MIME 类型

这些值容易被客户端控制。实际服务端应结合扩展名白名单、文件头检测、业务场景限制共同判断。

3. 文件名必须由服务端生成

使用原始文件名保存,常见问题包括路径穿越、覆盖同名文件、特殊字符导致后续处理异常、日志污染等。建议保存时使用随机 ID 或哈希命名,原始文件名只作为元数据保存到数据库。

String safeName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
Path target = uploadRoot.resolve(safeName).normalize();

if (!target.startsWith(uploadRoot)) {
    throw new SecurityException("invalid upload path");
}

Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);

这里的 normalize 和 startsWith 很关键,能够避免保存路径被异常输入影响。即使文件名已经改成 UUID,也建议保留这类防御式检查。

4. 上传目录不要放在 Web 解析路径下

比较稳的方案是把上传文件放在应用运行目录之外,访问时通过受控下载接口读取文件流返回,而不是直接暴露物理路径。这样可以做到鉴权、审计、限速、响应头控制。

app:
  upload:
    root: /data/app-uploads

下载接口返回时建议明确设置响应头,避免浏览器对某些内容进行自动解释:

response.setHeader("Content-Disposition", "attachment; filename=\"download.bin\"");
response.setHeader("X-Content-Type-Options", "nosniff");
response.setContentType("application/octet-stream");

如果确实需要图片预览,也应只允许经过确认的图片类型,并单独走预览接口,不建议把整个上传目录映射成静态资源目录。

5. 图片处理要注意解析库风险

很多人只关注脚本执行风险,但图片处理链路也值得关注。上传图片后,如果后端会压缩、裁剪、读取 EXIF 信息,就会调用图像处理库。历史上不少图片解析库出现过内存消耗、崩溃甚至更严重的问题。

建议做几件事:

  • 限制文件大小和图片像素尺寸
  • 图片处理放在隔离任务中执行,避免拖垮主业务线程
  • 及时更新 ImageMagick、GraphicsMagick、libwebp 等依赖
  • 处理失败时不要把异常堆栈直接返回给用户
BufferedImage image = ImageIO.read(file.getInputStream());
if (image == null) {
    throw new IllegalArgumentException("invalid image");
}
if (image.getWidth() > 5000 || image.getHeight() > 5000) {
    throw new IllegalArgumentException("image too large");
}

6. 文档预览链路要单独评估

很多系统会提供 Office、PDF 预览。这个功能经常被忽视,因为上传本身看起来只是保存文件,但后台可能会调用转换服务,把文档转成 HTML、图片或 PDF。

审计时要确认:

  • 转换服务是否与主应用隔离
  • 转换工具是否为最新版本
  • 转换输出是否会直接嵌入页面
  • 是否禁止外部资源加载
  • 转换任务是否有超时和资源限制

尤其是把文档内容转换为 HTML 并展示时,需要对输出进行严格处理,避免把不可信内容直接拼进页面。

修复建议

这类问题不建议逐个接口打补丁,比较可靠的方式是沉淀一个统一上传组件。一个基础版本至少应包含以下能力:

  • 统一文件大小限制
  • 统一扩展名白名单
  • 统一内容类型识别
  • 服务端生成保存文件名
  • 上传目录与 Web 根目录隔离
  • 文件元数据入库
  • 下载和预览接口统一鉴权
  • 关键操作记录审计日志

可以把上传策略按业务分类,而不是让每个接口自己决定。例如:

业务类型允许格式最大大小访问方式
头像jpg、jpeg、png2MB受控预览
工单附件jpg、png、pdf10MB鉴权下载
批量导入xlsx5MB导入后不公开访问
后台资料pdf、docx、xlsx20MB鉴权下载

配置化策略比硬编码更容易维护,但配置入口也要受控,避免把安全边界变成后台可随意修改的开关。

验证方式

修复完成后,不要只测正常上传,还要覆盖异常路径。建议至少验证以下内容:

  • 不在白名单内的扩展名是否被拒绝
  • 扩展名和内容类型不一致时是否被拒绝
  • 超大文件是否在应用层或网关层被拦截
  • 文件名中的特殊字符是否不会影响保存路径
  • 上传后的文件是否不能通过物理路径直接访问
  • 下载接口是否必须鉴权
  • 不同用户是否不能访问彼此无权限的附件
  • 错误信息是否不会泄露服务端路径和堆栈

如果使用 Nginx,可以在网关层先做大小限制,减少应用压力:

server {
    client_max_body_size 20m;

    location /api/ {
        proxy_pass 
    }
}

应用层仍然要做大小限制,因为网关配置不一定覆盖所有调用路径,内部服务调用、测试环境、异步上传接口都可能绕开统一入口。

注意点

  • 不要把“禁止某些后缀”作为主要策略,应该使用允许列表。
  • 不要因为对象存储有访问控制就省略业务鉴权,下载 URL 的生成也需要权限判断。
  • 不要在日志中记录完整敏感文件内容,记录文件 ID、大小、类型、操作者即可。
  • 不要让上传文件参与命令行拼接,确需调用外部工具时使用参数数组并限制工作目录。
  • 不要忽视清理机制,上传失败、中间文件、转换缓存都需要定期处理。
上传安全的核心不是某一个校验函数,而是“文件从进入系统到被读取、展示、转换、下载”的完整生命周期控制。

总结

文件上传模块看似基础,但非常适合暴露系统里的工程治理问题:规则分散、权限缺失、存储混乱、预览链路不清晰。我的经验是,上传安全最好不要等到出现问题后才补丁式修复,而应该作为基础组件统一收口。

实际落地时,可以先做三件事:第一,梳理所有上传入口;第二,把文件存储从 Web 可解析路径中迁出;第三,建立统一的上传策略和下载鉴权。完成这三步后,再逐步补充内容识别、预览隔离、转换服务沙箱、审计日志等能力,整体风险会下降很多。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…