文件上传类问题经常被简单归结为“校验扩展名不严格”,但在实际项目中,风险通常来自一整条处理链路:请求解析、文件名规范化、类型判断、临时目录落盘、压缩包解包、对象存储上传、静态文件映射以及后续异步处理。单点修补往往只能降低风险,不能替代完整的边界设计。本文以一次典型的代码审计场景为背景,整理文件上传功能中比较容易被忽略的风险点,并给出适合落地到研发流程中的审计和修复方法。示例均面向隔离测试环境和防守性验证,不涉及对真实系统的攻击操作。一、先明确需要保护的边界审计上传功能之前,建议先回答四个问题:上传文件最终保存在哪里,是本地目录、共享目录,还是对象存储?上传后的文件是否会被 Web 服务器直接映射访问?文件是否会进入图片解析、文档预览、压缩包解包或杀毒扫描流程?上传者能否影响最终文件名、目录层级、Content-Type 或处理参数?这几项决定了风险类型。文件本身不一定需要被服务器直接执行,才会产生安全问题。例如,路径处理缺陷可能导致覆盖应用配置;压缩包中的符号链接可能影响解包结果;图片解析器缺陷可能在异步处理阶段触发;对象存储的公开策略错误则可能造成敏感文件泄露。二、审计时不要只看扩展名校验常见代码会同时检查文件扩展名和请求头中的 Content-Type,但这两个字段都属于客户端可控输入,不能单独作为安全边界。String name = multipartFile.getOriginalFilename();\nString contentType = multipartFile.getContentType();\nif (name.endsWith(".jpg") && "image/jpeg".equals(contentType)) {\n save(name, multipartFile.getInputStream());\n}这段代码至少存在几个审计疑点:文件名可能为空,也可能包含路径分隔符、控制字符或不同平台的路径语义。endsWith 判断没有统一大小写和 Unicode 规范化,容易造成规则不一致。Content-Type 是请求元数据,不代表文件内容确实是 JPEG。save 方法的实现决定了是否存在目录穿越、覆盖文件和权限继承问题。即使内容是合法图片,后续处理链路仍可能受到解析器或缩略图组件影响。更可靠的做法是:服务端生成随机存储名,扩展名由服务端根据实际检测结果决定;文件内容通过受控解析器或魔数检测进行初步识别;最终存储目录与可执行目录隔离,并通过权限和访问层再次限制。三、文件名处理:不要试图“修正”用户提供的路径文件名的主要用途通常只是展示。若业务不需要保留原始名称,就不应把原始名称作为物理路径的一部分。推荐将展示名称和存储名称分离:String displayName = sanitizeForDisplay(originalName);\nString storageName = UUID.randomUUID().toString().replace("-", "");\nPath storagePath = uploadRoot.resolve(storageName).normalize();\n\nif (!storagePath.getParent().equals(uploadRoot)) {\n throw new IllegalArgumentException("invalid storage path");\n}\n这里的 normalize 只能作为辅助检查,不能代替目录权限隔离。审计时应继续确认:uploadRoot 是否为固定的绝对路径,而不是来自请求参数或可修改配置。是否使用了 realpath 或等价机制处理符号链接带来的路径变化。目标文件已存在时是否会被覆盖。临时文件和最终文件是否位于同一目录,是否存在竞争窗口。Windows 与 Unix 路径分隔符、保留设备名、尾随空格和大小写差异是否被统一处理。如果业务确实需要保留原文件名,建议把它作为数据库字段或对象元数据保存,下载时通过业务接口返回,不要直接拼接到本地物理路径。四、目录穿越检查的正确落点对于需要接收相对路径的特殊场景,例如导入项目目录、恢复备份或处理归档文件,应将路径校验放在“解析出最终目标路径之后”,而不是只检查原始字符串。Path root = Paths.get("/srv/app/import").toRealPath();\nPath candidate = root.resolve(entryName).normalize();\n\nif (!candidate.startsWith(root)) {\n throw new SecurityException("entry outside import root");\n}\n实际实现中还要处理符号链接。一个常见错误是先通过 startsWith 检查,再创建目录或写入文件;如果中间路径可以被替换为符号链接,检查结果就可能失效。对于不可信归档,建议:在独立临时目录中解包,目录使用随机名称,并设置严格权限。拒绝符号链接、硬链接以及设备文件等特殊条目。逐个条目检查规范化后的目标路径。限制条目数量、单文件大小、总解压大小和目录深度。解包完成后再进行业务校验,不要边解包边执行脚本或加载插件。五、压缩包处理中的 Zip Slip 与资源耗尽压缩包功能通常同时面临路径穿越和资源耗尽两类问题。前者关注解包目标,后者关注压缩比、文件数量和递归深度。防守性测试可以准备一个只包含普通文本的测试归档,分别验证以下情况是否被拒绝:包含上级目录片段的条目、使用绝对路径的条目、包含符号链接的条目、超出单文件大小限制的条目,以及条目总数超过阈值的归档。测试应在隔离环境中完成,并确认失败时不会留下半成品文件。long totalSize = 0;\nint entryCount = 0;\n\nwhile ((entry = archive.getNextEntry()) != null) {\n if (++entryCount > MAX_ENTRIES) {\n throw new SecurityException("too many entries");\n }\n\n Path target = root.resolve(entry.getName()).normalize();\n if (!target.startsWith(root) || entry.isSymbolicLink()) {\n throw new SecurityException("unsafe archive entry");\n }\n\n copyWithLimit(archive, target, MAX_ENTRY_SIZE);\n totalSize += Files.size(target);\n if (totalSize > MAX_TOTAL_SIZE) {\n throw new SecurityException("archive expands too much");\n }\n}这里的限制应当同时存在于应用层和基础设施层。应用层负责拒绝异常输入,容器或沙箱负责限制 CPU、内存、磁盘和进程权限。只做其中一层,遇到解析器异常或逻辑遗漏时仍然可能造成可用性问题。六、内容识别与解析链路文件类型判断建议采用分层策略:第一层检查大小、数量、名称长度和请求整体大小。第二层读取有限长度的文件头,做基础格式判断。第三层由专用解析器验证结构完整性。第四层根据业务需要进行重新编码,例如将用户上传的图片解码后重新保存为受控格式。“重新编码”并不是万能的安全措施,但可以去除一部分非业务必要的数据和元信息。处理不可信文档时,不应在主应用进程中直接调用高权限解析组件。更稳妥的架构是将解析任务放到低权限、无外网或受限网络的独立工作进程中,并通过队列传递任务。文件校验解决的是“它看起来是什么”,解析隔离解决的是“即使它有问题,影响范围有多大”。两者不能互相替代。七、存储与访问控制是另一条边界很多上传漏洞并不发生在写入阶段,而发生在访问阶段。建议将上传内容放在 Web 根目录之外,通过应用接口读取,并由应用根据数据库中的所有权和状态进行授权判断。如果业务必须使用对象存储,应重点检查以下配置:桶或容器是否默认公开。上传接口是否允许客户端直接指定对象键。下载链接是否为长期有效的公开链接。响应头是否会把用户上传内容当作 HTML 或脚本解释。跨域策略、缓存策略和内容处置头是否符合业务需求。Content-Disposition: attachment; filename="download.bin"\nX-Content-Type-Options: nosniff\nContent-Security-Policy: default-src 'none'\n响应头不能替代服务端授权,但可以减少浏览器对不可信内容的错误解释。对图片、文档和文本等不同类型,应明确允许的展示方式,不要让所有上传内容都复用同一套响应策略。八、如何在代码审计中快速定位问题实际审计可以从数据流而不是从文件名关键字开始。先定位上传入口,再追踪以下变量是否流向危险操作:原始文件名是否流向 Paths.get、File、open、move 或 rename。用户提供的目录参数是否流向解包、备份恢复或导出功能。上传内容是否流向模板渲染、脚本执行、动态加载或命令调用。文件类型判断结果是否仅来自扩展名或请求头。异步任务是否使用了与主应用不同的权限和目录。在大型项目中,可以结合静态规则进行初筛。例如,搜索文件写入和路径拼接调用,再回溯其参数来源:grep -RInE 'getOriginalFilename|Paths\.get|new File|resolve\(|ZipInputStream|extract|renameTo' src/工具只能帮助缩小范围,最终仍需确认数据流、权限上下文和部署配置。尤其要避免看到 sanitize、normalize 或 checkFileType 这类函数名就直接判定“已经安全”,应继续检查它们的调用顺序和失败处理逻辑。九、修复后的验证清单检查项验证目标文件名原始名称不参与物理路径生成,展示名称经过长度和字符限制存储名由服务端生成,重复上传不会覆盖已有文件路径规范化后始终位于固定根目录,符号链接不会绕过检查类型不依赖扩展名和 Content-Type,解析失败会拒绝入库压缩包拒绝危险条目,并限制数量、大小、深度和解压总量权限上传、解析、下载使用最小权限,目录不具备不必要的执行权限访问下载前进行业务授权,不直接暴露本地文件系统审计记录上传者、文件哈希、处理结果和异常原因,避免记录敏感内容十、总结文件上传安全的核心不是增加几个黑名单规则,而是把不可信数据限制在明确的边界内:物理路径由服务端控制,内容由受控组件解析,存储目录与执行环境隔离,压缩包受到资源限制,访问过程经过业务授权。从审计角度看,最有效的方法是沿着“输入—落盘—解析—存储—访问”的完整数据流检查,而不是只盯着扩展名校验。修复完成后,还应将目录穿越、归档解包、超大文件、异常解析和权限绕过纳入回归测试,避免同类问题在其他上传入口重复出现。
推荐意见