diff --git a/docs/file-access/README.md b/docs/file-access/README.md index 2339639dc..f5de41374 100644 --- a/docs/file-access/README.md +++ b/docs/file-access/README.md @@ -103,6 +103,8 @@ nonce 默认单文件最大 200 MiB,仍受现有网关、反向代理和 Servlet 更低限制约束。SVG 最大 1 MiB,只接受静态内容,拒绝脚本、事件、外部引用和实体。服务层在写存储或文件记录前统一校验后缀、文件头及适用的图片尺寸/ZIP 容器结构;APK 检查二进制 AndroidManifest,Office 新格式检查对应容器入口。存储后缀和 MIME 由服务端规范化,不信任客户端 Content-Type。这里的格式检查不等于病毒或文档宏扫描。 +JPEG、PNG、GIF、BMP、WebP 位图按实际文件头识别和校验:声明后缀与实际格式均须在对应白名单中,符合条件时使用实际格式保存,不修改图片字节。例如名为 `.png` 的合法 WebP 保存为 `.webp`、MIME 为 `image/webp`。声明为 EXE/PDF/SVG 等其他类别的文件不通过这个规则转成图片,SVG 和文档继续执行各自的严格检查。后台单图、多图及七牛图片上传默认支持 WebP。 + 缺少或错误身份、过期、重放为 403;重复或畸形头、多个文件部分为 400;不支持类型或内容不符为 415;超过本策略大小为 413。错误响应禁止缓存。文件类型检查在 `observe` 下仍执行,观察模式只放宽应用签名准入。监控为 `file.upload.requests`,标签 `outcome/app/mode`,不记录密钥、签名和登录 Token。 后台 Axios、通用 el-upload 和富文本上传,H5/小程序的 `uni.uploadFile` 拦截器,以及 Flutter `HttpManage` 为本系统上传路径生成新请求头;导入接口和第三方上传地址不追加本协议。`RemoteFileService` 专属 Feign 拦截器为每次内部上传生成 service 签名。上传成功返回值和保存的文件引用继续为稳定原始地址。 diff --git a/docs/file-access/verification.md b/docs/file-access/verification.md index c497f94ce..6de313387 100644 --- a/docs/file-access/verification.md +++ b/docs/file-access/verification.md @@ -115,3 +115,16 @@ Java 定向测试及各端构建命令见 [接入与发布说明](README.md)。 日志保存在本机 `/tmp/pj-upload-security-work/`。本次上传改造没有发布到运行中的 Java 服务,也没有修改 Nacos 或更换密钥。发布后的真实 multipart 联调、微信真机和旧 Flutter 设备验证仍需执行;MockMvc、内存 Feign 和本地构建不能代替现场验收。 已有 `enforce` 环境先发布客户端及携带新公共依赖的 Java 调用服务,再发布 UPMS,避免仅更新 UPMS 后拒绝旧上传请求。当前旧 Flutter 取证/日志先经过硬件业务接口,再由 Java 调用文件中心,服务端补签后不要求这些旧设备立即升级。`observe` 会允许旧上传身份请求,切到 `enforce` 才真正拦截未签名上传;白名单在两种模式下均有效。发布配置与详细头协议见 README 的“上传身份与文件类型”。 + +## 普通位图后缀兼容补充 + +2026-10-29 复现商户 Logo 上传失败:用户提供的 `.png` 文件实际为 RIFF/VP8 WebP,800×440、17,436 字节,独立图片解码验证正常。旧规则按 PNG 文件头判断,因此返回 415。 + +现对声明为 JPG/JPEG/PNG/GIF/BMP/WebP 的文件先识别实际位图格式,检查实际格式也在白名单后再执行对应内容校验,保存时使用实际扩展名和 MIME。原图片字节不变;限制白名单仍生效,其他文件类别不通过这个规则自动转为图片。后台单图、多图和七牛图片上传默认加入 WebP,并让失败的 Promise 交给 Element Plus 统一处理,避免失败后继续触发成功提示。 + +- Java 32 项定向测试通过,含这张原图、公开合成 WebP、严格白名单、脚本/文档不自动转为图片、三个上传入口的 `.webp` 存储结果、原读取鉴权及安装包回归。 +- 后台 30 项定向测试通过,含三个图片组件的 WebP 选择、错误提示及原始返回地址保存。 +- 后台生产构建通过;上传相关组件及其依赖的 TypeScript 诊断由既有 115 条减为 114 条,去掉类型展开差异后无新增诊断。 +- 实际图只通过本机可选测试参数读取,没有作为仓库测试素材提交。验证日志在 `/tmp/pj-upload-image-format-work/`。 + +后端识别逻辑需要发布或重启更新后的 UPMS 才会生效;本次未更改运行环境、密钥或 Nacos。 diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java index 4d168333e..fa143cf66 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicy.java @@ -29,6 +29,7 @@ public class FileUploadPolicy { public record Accepted(String extension, String contentType) { } private static final Pattern UNSAFE_CSS = Pattern.compile("(?is)@import|expression\\s*\\("); private static final Pattern CSS_URL = Pattern.compile("(?is)url\\s*\\(([^)]*)\\)"); + private static final Set RASTER_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp"); public Accepted validate(MultipartFile file, String dir, boolean internal) { if (file == null || file.isEmpty() || file.getSize() <= 0) reject(400, "上传文件不能为空"); @@ -40,10 +41,20 @@ public class FileUploadPolicy { int dot = name.lastIndexOf('.'); String ext = dot < 1 ? "" : name.substring(dot + 1).toLowerCase(Locale.ROOT); var policy = properties.getUpload(); - if (!policy.getAllowedExtensions().contains(ext) && !(internal && policy.getInternalExtensions().contains(ext))) reject(415, "不支持上传此文件类型"); + if (!allowedExtension(ext, internal)) reject(415, "不支持上传此文件类型"); if (file.getSize() > policy.getMaxFileBytes()) reject(413, "文件超过服务器上传大小限制"); try (InputStream input = file.getInputStream()) { byte[] head = input.readNBytes(4096); + if (RASTER_EXTENSIONS.contains(ext)) { + String actual = rasterExtension(head); + if (actual != null) { + if (actual.equals("jpg") && (ext.equals("jpeg") || !allowedExtension("jpg", internal))) actual = "jpeg"; + if (!allowedExtension(actual, internal)) reject(415, "图片实际格式为 " + actual.toUpperCase(Locale.ROOT) + ",服务器未允许上传此类型"); + // Clipboard/download tools can name WebP bytes .png. Keep the + // original bytes and normalize only to a verified allowed image type. + ext = actual; + } + } boolean valid = switch (ext) { case "jpg", "jpeg" -> starts(head, 0xff, 0xd8, 0xff) && imageDimensions(file); case "png" -> starts(head, 137, 80, 78, 71, 13, 10, 26, 10) && imageDimensions(file); @@ -74,6 +85,20 @@ public class FileUploadPolicy { catch (Exception error) { throw new FileUploadRejectedException(415, "无法识别文件内容或文件已损坏"); } } + private boolean allowedExtension(String extension, boolean internal) { + var policy = properties.getUpload(); + return policy.getAllowedExtensions().contains(extension) || (internal && policy.getInternalExtensions().contains(extension)); + } + + private static String rasterExtension(byte[] head) { + if (starts(head, 137, 80, 78, 71, 13, 10, 26, 10)) return "png"; + if (starts(head, 0xff, 0xd8, 0xff)) return "jpg"; + if (ascii(head, 0, "GIF87a") || ascii(head, 0, "GIF89a")) return "gif"; + if (ascii(head, 0, "BM")) return "bmp"; + if (ascii(head, 0, "RIFF") && ascii(head, 8, "WEBP")) return "webp"; + return null; + } + private boolean imageDimensions(MultipartFile file) throws IOException { try (var stream = new MemoryCacheImageInputStream(file.getInputStream())) { Iterator readers = ImageIO.getImageReaders(stream); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadControllerTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadControllerTest.java index 5c27b28de..116d68f11 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadControllerTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadControllerTest.java @@ -101,4 +101,16 @@ class FileUploadControllerTest { .andExpect(status().isOk()).andExpect(jsonPath("$.code").value(0)); verify(h.storage, times(1)).putObject(eq("bucket"), isNull(), anyString(), any(InputStream.class), eq("image/png")); } + @Test void pngNamedWebpIsStoredWithItsActualExtensionAndMimeOnAllUploadRoutes() throws Exception { + var file = new MockMultipartFile("file", "logo.png", "image/png", webp()); + for (String target : List.of("upload", "inner-upload", "qn-upload")) { + var h = new Harness(); + String path = target.equals("qn-upload") ? "/sys-file/qn/upload" : "/sys-file/" + target; + String app = target.equals("inner-upload") ? "service" : "admin"; + h.mvc.perform(request(path, target, app, NONCE, file)).andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)).andExpect(jsonPath("$.data.fileName", matchesPattern("[a-f0-9]{32}\\.webp"))); + if (target.equals("qn-upload")) verify(h.qn).upload(eq(file), matches("[a-f0-9]{32}\\.webp")); + else verify(h.storage).putObject(eq("bucket"), isNull(), matches("[a-f0-9]{32}\\.webp"), any(InputStream.class), eq("image/webp")); + } + } } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicyTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicyTest.java index cfd8bebff..f28343062 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicyTest.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadPolicyTest.java @@ -15,6 +15,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Set; import static com.bsd.pjcloud.admin.service.file.FileUploadTestSupport.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -59,6 +60,27 @@ class FileUploadPolicyTest { String deep = "" + "".repeat(300) + "".repeat(300) + ""; assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("deep.svg", deep.getBytes()), null, false)); } + @Test void ordinaryRasterImagesUseTheirActualAllowedFormatInsteadOfMisleadingNamesOrMime() throws Exception { + var accepted = policy().validate(new MockMultipartFile("file", "logo.PNG", "image/png", webp()), null, false); + assertEquals("webp", accepted.extension()); assertEquals("image/webp", accepted.contentType()); + assertEquals("jpg", policy().validate(file("logo.png", image("jpeg")), null, false).extension()); + assertEquals("png", policy().validate(file("logo.jpg", image("png")), null, false).extension()); + assertEquals("jpeg", policy().validate(file("logo.jpeg", image("jpeg")), null, false).extension()); + assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("logo.exe", webp()), null, false)); + assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("logo.pdf", webp()), null, false)); + var restricted = properties("enforce"); restricted.getUpload().setAllowedExtensions(Set.of("png")); + assertEquals(415, assertThrows(FileUploadRejectedException.class, + () -> new FileUploadPolicy(restricted).validate(file("logo.png", webp()), null, false)).getStatus()); + restricted.getUpload().setInternalExtensions(Set.of("jpg")); + assertEquals("jpg", new FileUploadPolicy(restricted).validate(file("logo.png", image("jpeg")), null, true).extension()); + } + @Test + @EnabledIfSystemProperty(named = "upload.image.fixture", matches = ".+") + void suppliedPngNamedWebpIsAcceptedAndNormalizedWithoutChangingItsBytes() throws Exception { + Path path = Path.of(System.getProperty("upload.image.fixture")); + var accepted = policy().validate(file("logo.png", Files.readAllBytes(path)), null, false); + assertEquals("webp", accepted.extension()); assertEquals("image/webp", accepted.contentType()); + } @Test void logFilesAreInternalOnlyAndSizeAndNamesAreCheckedBeforeStorage() { byte[] log = "日志\n\u001b[32mmessage\u001b[0m".getBytes(StandardCharsets.UTF_8); assertThrows(FileUploadRejectedException.class, () -> policy().validate(file("terminal.log", log), null, false)); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadTestSupport.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadTestSupport.java index a2c4760db..8e5bec1d2 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadTestSupport.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/file/FileUploadTestSupport.java @@ -33,6 +33,10 @@ final class FileUploadTestSupport { if (!ImageIO.write(new BufferedImage(2, 2, BufferedImage.TYPE_INT_RGB), format, out)) throw new IllegalArgumentException(format); return out.toByteArray(); } + static byte[] webp() { + // Public synthetic 2x3 lossless WebP, independent of deployment data. + return Base64.getDecoder().decode("UklGRh4AAABXRUJQVlA4TBEAAAAvAYAAAAdQrXoUtP+BiOh/AAA="); + } static byte[] zip(Map files) throws Exception { var out = new ByteArrayOutputStream(); try (var zip = new ZipOutputStream(out)) {