背景文件上传是 Web 系统里很常见的功能,但也是代码审计中比较容易被低估的风险点。很多团队在实现时会关注文件大小、后缀名和存储路径,却忽略了文件内容校验、路径拼接、静态资源映射、异常处理等细节。最终问题不一定表现为直接的远程代码执行,也可能是任意文件覆盖、敏感文件落盘、存储型 XSS、资源消耗或内部路径泄露。这篇文章整理一次 Spring Boot 项目中文件上传模块的排查思路。内容偏防守和审计视角,重点放在如何识别风险、验证问题边界以及给出可落地的修复方案,不涉及违法攻击或未授权利用。场景描述某业务系统提供头像上传和附件上传两个接口,后端使用 Spring Boot,上传文件存储在服务器本地目录,再通过静态资源映射对外访问。简化后的代码形态如下:@PostMapping(\"/upload\")\npublic String upload(@RequestParam(\"file\") MultipartFile file) throws IOException {\n String originalName = file.getOriginalFilename();\n String suffix = originalName.substring(originalName.lastIndexOf(\".\"));\n String fileName = UUID.randomUUID() + suffix;\n\n File target = new File(uploadDir + \"/\" + fileName);\n file.transferTo(target);\n\n return \"/files/\" + fileName;\n}表面看代码使用了 UUID 重命名,似乎避免了直接使用原始文件名带来的覆盖问题。但实际审计时不能只看单点,还要结合配置、网关、静态资源映射和业务调用方式一起判断。主要风险点分析1. 仅依赖后缀名判断文件类型很多项目会用后缀名白名单限制文件类型,例如只允许 jpg、png、pdf、docx。但后缀名来自客户端,可信度很低。如果后端只判断文件名后缀,而不校验文件内容,就可能出现“内容与扩展名不一致”的情况。安全的做法是后缀名、Content-Type、文件头魔数和业务预期一起判断。注意 Content-Type 也可被客户端控制,不能单独作为依据。private static final Set<String> ALLOW_EXT = Set.of(\"jpg\", \"jpeg\", \"png\", \"pdf\");
private boolean isAllowedExt(String filename) {
if (filename == null || !filename.contains(\".\")) {
return false;
}
String ext = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
return ALLOW_EXT.contains(ext);
}对于图片类文件,可以尝试使用 ImageIO 解码验证;对于 PDF、Office 文档等类型,需要结合文件头特征和业务解析库做更细的判断。private boolean isValidImage(MultipartFile file) {
try (InputStream in = file.getInputStream()) {
return ImageIO.read(in) != null;
} catch (IOException e) {
return false;
}
}2. 上传目录与 Web 可执行目录混用上传目录如果被映射为静态资源目录,风险会明显上升。尤其在某些传统 Java Web 环境中,如果上传路径位于 webroot、static、templates 等目录附近,可能出现非预期访问、模板解析或脚本执行风险。比较稳妥的原则是:上传文件存储目录与应用部署目录隔离,避免落在可执行路径下;对外访问时通过受控下载接口或独立对象存储访问,不直接暴露本地文件系统结构。# application.yml 示例\napp:\n upload:\n base-dir: /data/app-upload/files如果确实需要本地静态访问,也建议只映射不可执行资源,并配合响应头限制浏览器行为。@GetMapping(\"/files/{id}\")\npublic ResponseEntity<Resource> download(@PathVariable String id) {\n // 根据 id 查询数据库中的文件记录,不直接拼接用户输入路径\n // 校验权限、文件状态、归属关系后再返回\n return ResponseEntity.ok()\n .header(\"X-Content-Type-Options\", \"nosniff\")\n .header(\"Content-Disposition\", \"attachment; filename=\\\"download.bin\\\"\")\n .body(resource);\n}3. 路径拼接不规范导致目录穿越风险示例代码里虽然保存文件时使用了 UUID,但实际项目中经常存在按业务目录分类的写法,例如用户传入 type、path、folder 等参数再拼接到上传目录。如果没有做规范化处理,就可能产生目录穿越或越权写入风险。// 不推荐\nFile target = new File(baseDir + \"/\" + userInputDir + \"/\" + fileName);更稳妥的处理方式是使用 Path 进行 resolve 和 normalize,并确认最终路径仍位于预期的 baseDir 下。Path basePath = Paths.get(baseDir).toAbsolutePath().normalize();\nPath targetPath = basePath.resolve(fileName).normalize();\n\nif (!targetPath.startsWith(basePath)) {\n throw new SecurityException(\"invalid upload path\");\n}\n\nFiles.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING);如果业务上需要分类目录,不建议直接使用用户传入的目录名,而应使用后端枚举映射。enum UploadScene {\n AVATAR(\"avatar\"),\n ATTACHMENT(\"attachment\");\n\n private final String dir;\n UploadScene(String dir) { this.dir = dir; }\n public String dir() { return dir; }\n}4. 文件名处理中的边界问题审计时还要注意 originalFilename 的边界情况。不同客户端上传时,文件名可能为空、包含路径分隔符、包含特殊字符,甚至是非预期编码。如果代码直接 substring,很容易触发异常,造成接口 500 或日志污染。建议保存时完全不要信任原始文件名。原始文件名最多用于展示,并且需要做长度限制和字符清洗。真实落盘文件名使用服务端生成的随机 ID。private String safeOriginalName(String name) {\n if (name == null) {\n return \"unknown\";\n }\n name = Paths.get(name).getFileName().toString();\n name = name.replaceAll(\"[\\\\r\\\\n]\", \"_\");\n if (name.length() > 120) {\n name = name.substring(0, 120);\n }\n return name;\n}5. 存储型 XSS 与内容嗅探文件上传不只影响服务端,也会影响前端用户。如果允许上传 SVG、HTML、XML 等可包含脚本或主动内容的文件,并且浏览器以内联方式打开,就可能引发存储型 XSS。即便文件扩展名是图片,如果响应头设置不当,仍可能触发浏览器嗅探行为。常见控制措施包括:默认使用下载方式返回用户上传文件,避免直接 inline 展示不可信内容。设置 X-Content-Type-Options: nosniff。对头像等必须预览的图片,服务端重新解码并转码,去除异常元数据和嵌入内容。谨慎允许 SVG,除非经过严格清洗并确认业务确实需要。6. 文件大小与资源消耗上传接口还需要考虑资源消耗问题。只在业务代码里判断大小是不够的,因为请求进入 Controller 前,容器和框架可能已经开始处理 multipart 数据。应同时在网关、Spring 配置和业务层做限制。spring:\n servlet:\n multipart:\n max-file-size: 10MB\n max-request-size: 20MB如果前面有 Nginx,也应设置请求体大小限制:server {\n client_max_body_size 20m;\n}业务层仍建议保留显式判断,便于返回更清晰的错误信息。if (file.isEmpty()) {\n throw new IllegalArgumentException(\"empty file\");\n}\nif (file.getSize() > 10 * 1024 * 1024) {\n throw new IllegalArgumentException(\"file too large\");\n}建议的安全上传流程结合这次排查,比较推荐的上传处理流程如下:接口层先校验登录态、业务权限和上传场景。限制请求大小、单文件大小和文件数量。清洗原始文件名,仅作为展示字段保存。使用服务端生成的随机文件 ID,避免用户控制落盘文件名。通过白名单判断扩展名,并结合文件头或解析库验证内容。使用 Path.normalize 校验最终路径必须在上传根目录下。文件落盘后记录数据库元信息,包括 owner、scene、hash、size、mime、状态。访问文件时走受控下载接口,校验权限,不直接暴露真实路径。响应头设置 nosniff,默认 attachment 下载。记录必要审计日志,但不要打印完整本地路径和敏感参数。一个相对完整的保存示例public StoredFile save(MultipartFile file, UploadScene scene, Long userId) throws IOException {\n if (file == null || file.isEmpty()) {\n throw new IllegalArgumentException(\"empty file\");\n }\n if (file.getSize() > MAX_SIZE) {\n throw new IllegalArgumentException(\"file too large\");\n }\n\n String originalName = safeOriginalName(file.getOriginalFilename());\n if (!isAllowedExt(originalName)) {\n throw new IllegalArgumentException(\"file type not allowed\");\n }\n\n String ext = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);\n String storedName = UUID.randomUUID().toString().replace(\"-\", \"\") + \".\" + ext;\n\n Path basePath = Paths.get(uploadBaseDir).toAbsolutePath().normalize();\n Path scenePath = basePath.resolve(scene.dir()).normalize();\n Files.createDirectories(scenePath);\n\n Path targetPath = scenePath.resolve(storedName).normalize();\n if (!targetPath.startsWith(scenePath)) {\n throw new SecurityException(\"invalid path\");\n }\n\n try (InputStream in = file.getInputStream()) {\n Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING);\n }\n\n StoredFile stored = new StoredFile();\n stored.setOwnerId(userId);\n stored.setScene(scene.name());\n stored.setOriginalName(originalName);\n stored.setStoredName(storedName);\n stored.setSize(file.getSize());\n stored.setPath(targetPath.toString());\n return stored;\n}审计时可以重点 grep 的内容做代码审计时,可以优先搜索以下关键点,通常能较快定位上传相关风险:MultipartFile\ngetOriginalFilename\ntransferTo\nFiles.copy\nFileOutputStream\nResourceHandlerRegistry\naddResourceLocations\n/static/\n/upload/\nContent-Disposition\nX-Content-Type-Options同时建议检查配置文件中的静态资源映射和上传目录配置,很多问题不在 Controller 代码本身,而在“上传目录被映射到公网可访问路径”这个组合上。修复验证清单检查项验证方式后缀白名单上传非允许后缀,确认被拒绝内容校验上传扩展名与内容不一致的文件,确认被拒绝或转为安全处理路径规范化包含特殊路径字符的参数不影响最终存储目录权限校验不同用户不能访问彼此的私有附件响应头下载响应包含 nosniff,非可信文件默认 attachment大小限制超过限制的请求在网关或应用层被拒绝日志安全异常日志不暴露完整服务器路径和敏感参数注意点不要把“UUID 文件名”误认为完整安全方案,它只能降低覆盖和猜测风险。不要只依赖前端限制,上传安全必须在服务端完成。不要把用户上传文件放到模板目录、类路径目录或应用部署目录下。图片预览场景建议服务端重新编码,减少异常格式带来的浏览器侧风险。对象存储也不是天然安全,仍需要控制 ACL、Content-Type、下载方式和访问权限。总结文件上传风险通常不是某一行代码造成的,而是多个小问题叠加:文件类型判断不严、路径拼接随意、上传目录暴露、下载接口缺少权限、响应头不安全。审计时建议按“入口校验、存储路径、访问方式、权限控制、响应行为”这条线走一遍,基本能覆盖大部分实际风险。修复上也不用追求复杂方案,核心是把不可信输入和可执行环境隔离开,用白名单和规范化处理收紧边界,再通过受控下载接口统一做权限和响应头控制。这样即使后续业务继续扩展上传场景,也不容易反复踩同样的问题。
推荐意见