diff --git a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/document/AgentDocumentService.java b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/document/AgentDocumentService.java index 95d2a3a8..abb025fa 100644 --- a/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/document/AgentDocumentService.java +++ b/easyflow-modules/easyflow-module-agent/src/main/java/tech/easyflow/agent/runtime/document/AgentDocumentService.java @@ -56,7 +56,7 @@ public class AgentDocumentService { private static final byte[] OLE_SIGNATURE = { (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 }; - private static final Map> MIME_TYPES = mimeTypes(); + private static final Map CANONICAL_MIME_TYPES = canonicalMimeTypes(); private final AgentDocumentAttachmentMapper attachmentMapper; private final AgentDocumentSnapshotMapper snapshotMapper; @@ -701,8 +701,8 @@ public class AgentDocumentService { throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE, "该类型文档不能超过 " + limit / (1024 * 1024) + " MiB"); } - String mimeType = normalizeMime(file.getContentType(), extension); validateSignature(file, extension); + String mimeType = normalizeMime(file.getContentType(), extension); return new DocumentFile(originalName, extension, mimeType, file.getSize()); } @@ -768,15 +768,27 @@ public class AgentDocumentService { return properties.getLimits().getOfficeMaxBytes().toBytes(); } + /** + * 将客户端声明的 MIME 规范为服务端可信类型。 + * + * @param declared 客户端声明 MIME + * @param extension 已校验扩展名 + * @return 服务端规范 MIME + */ private String normalizeMime(String declared, String extension) { String normalized = declared == null ? "" : declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT); - Set allowed = MIME_TYPES.get(extension); - if (StringUtils.hasText(normalized) && !"application/octet-stream".equals(normalized) - && (allowed == null || !allowed.contains(normalized))) { - throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, - "文件 MIME 类型与扩展名不一致"); + String canonical = CANONICAL_MIME_TYPES.get(extension); + if (!StringUtils.hasText(canonical)) { + throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "不支持该文档类型"); } - return allowed.iterator().next(); + // 浏览器 MIME 由系统注册表或桌面 MIME 数据库决定,只用于诊断,最终类型由扩展名和文件内容校验确定。 + if (StringUtils.hasText(normalized) + && !"application/octet-stream".equals(normalized) + && !canonical.equals(normalized)) { + LOG.info("Normalize Agent document MIME alias: extension={}, declaredMime={}, canonicalMime={}", + extension, normalized, canonical); + } + return canonical; } private String safeOriginalName(String value) { @@ -904,17 +916,22 @@ public class AgentDocumentService { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private static Map> mimeTypes() { - Map> values = new LinkedHashMap<>(); - values.put("pdf", Set.of("application/pdf")); - values.put("doc", Set.of("application/msword")); - values.put("docx", Set.of("application/vnd.openxmlformats-officedocument.wordprocessingml.document")); - values.put("ppt", Set.of("application/vnd.ms-powerpoint")); - values.put("pptx", Set.of("application/vnd.openxmlformats-officedocument.presentationml.presentation")); - values.put("xls", Set.of("application/vnd.ms-excel")); - values.put("xlsx", Set.of("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")); - values.put("txt", Set.of("text/plain")); - values.put("md", Set.of("text/markdown", "text/plain")); + /** + * 创建受支持扩展名到服务端规范 MIME 的映射。 + * + * @return 不可变规范 MIME 映射 + */ + private static Map canonicalMimeTypes() { + Map values = new LinkedHashMap<>(); + values.put("pdf", "application/pdf"); + values.put("doc", "application/msword"); + values.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + values.put("ppt", "application/vnd.ms-powerpoint"); + values.put("pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation"); + values.put("xls", "application/vnd.ms-excel"); + values.put("xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + values.put("txt", "text/plain"); + values.put("md", "text/markdown"); return Map.copyOf(values); } diff --git a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/document/AgentDocumentServiceTest.java b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/document/AgentDocumentServiceTest.java index 454d1244..736d6081 100644 --- a/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/document/AgentDocumentServiceTest.java +++ b/easyflow-modules/easyflow-module-agent/src/test/java/tech/easyflow/agent/runtime/document/AgentDocumentServiceTest.java @@ -6,6 +6,7 @@ import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; import tech.easyflow.agent.config.AgentDocumentProperties; import tech.easyflow.agent.entity.AgentDocumentAttachment; import tech.easyflow.agent.mapper.AgentDocumentAttachmentMapper; @@ -14,13 +15,22 @@ import tech.easyflow.agent.runtime.media.AgentMediaObjectStorage; import tech.easyflow.common.entity.LoginAccount; import java.io.ByteArrayInputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.List; /** * {@link AgentDocumentService} 上传幂等性测试。 */ public class AgentDocumentServiceTest { + private static final byte[] OLE_SIGNATURE = { + (byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1 + }; + private static final byte[] ZIP_SIGNATURE = {'P', 'K', 3, 4, 0, 0, 0, 0}; + private AgentDocumentAttachmentMapper attachmentMapper; private AgentMediaObjectStorage objectStorage; private AgentDocumentReadTaskProducer taskProducer; @@ -82,6 +92,61 @@ public class AgentDocumentServiceTest { Mockito.verify(taskProducer, Mockito.never()).send(Mockito.anyString()); } + /** + * 验证 WPS、系统注册表和旧式浏览器 MIME 别名在内容有效时均可上传。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void validateFileShouldAcceptClientMimeAliasesAndNormalizeCanonicalType() throws Exception { + List cases = List.of( + new MimeCase("sample.doc", "application/wps-office.doc", + OLE_SIGNATURE, "application/msword"), + new MimeCase("sample.docx", "application/wps-office.docx", + ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + new MimeCase("sample.xls", "application/wps-office.xls", + OLE_SIGNATURE, "application/vnd.ms-excel"), + new MimeCase("sample.xlsx", "application/x-zip-compressed", + ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + new MimeCase("sample.ppt", "application/x-ole-storage", + OLE_SIGNATURE, "application/vnd.ms-powerpoint"), + new MimeCase("sample.pptx", "application/zip", + ZIP_SIGNATURE, "application/vnd.openxmlformats-officedocument.presentationml.presentation"), + new MimeCase("sample.pdf", "application/x-pdf", + "%PDF-1.7".getBytes(StandardCharsets.US_ASCII), "application/pdf"), + new MimeCase("sample.txt", "text/x-log", + "plain text".getBytes(StandardCharsets.UTF_8), "text/plain"), + new MimeCase("sample.md", "text/x-markdown", + "# title".getBytes(StandardCharsets.UTF_8), "text/markdown")); + + for (MimeCase mimeCase : cases) { + Object document = invokeValidateFile(file(mimeCase)); + Assert.assertEquals(mimeCase.canonicalMime(), mimeType(document)); + } + } + + /** + * 验证客户端 MIME 即使伪装成 WPS 文档,文件头不匹配时仍会被拒绝。 + * + * @throws Exception 反射调用失败时抛出 + */ + @Test + public void validateFileShouldRejectInvalidContentDespiteCompatibleMimeAlias() throws Exception { + MimeCase mimeCase = new MimeCase( + "sample.doc", + "application/wps-office.doc", + "%PDF-1.7".getBytes(StandardCharsets.US_ASCII), + "application/msword"); + + try { + invokeValidateFile(file(mimeCase)); + Assert.fail("expected ResponseStatusException"); + } catch (ResponseStatusException error) { + Assert.assertEquals(415, error.getStatusCode().value()); + Assert.assertEquals("文件内容与扩展名不一致", error.getReason()); + } + } + /** * 构造一条已完成读取的正式聊天附件。 * @@ -104,4 +169,69 @@ public class AgentDocumentServiceTest { attachment.setStatus(AgentDocumentStatus.READY.name()); return attachment; } + + /** + * 创建指定名称、MIME 和内容的上传文件。 + * + * @param mimeCase MIME 测试用例 + * @return 上传文件 + * @throws Exception 模拟输入流失败时抛出 + */ + private MultipartFile file(MimeCase mimeCase) throws Exception { + MultipartFile file = Mockito.mock(MultipartFile.class); + Mockito.when(file.isEmpty()).thenReturn(false); + Mockito.when(file.getSize()).thenReturn((long) mimeCase.bytes().length); + Mockito.when(file.getOriginalFilename()).thenReturn(mimeCase.fileName()); + Mockito.when(file.getContentType()).thenReturn(mimeCase.declaredMime()); + Mockito.when(file.getInputStream()) + .thenAnswer(invocation -> new ByteArrayInputStream(mimeCase.bytes())); + return file; + } + + /** + * 调用文档文件校验。 + * + * @param file 上传文件 + * @return 内部文档描述 + * @throws Exception 校验失败或反射调用失败时抛出 + */ + private Object invokeValidateFile(MultipartFile file) throws Exception { + Method method = AgentDocumentService.class.getDeclaredMethod("validateFile", MultipartFile.class); + method.setAccessible(true); + try { + return method.invoke(service, file); + } catch (InvocationTargetException error) { + if (error.getCause() instanceof Exception cause) { + throw cause; + } + throw error; + } + } + + /** + * 读取内部文档描述的规范 MIME。 + * + * @param document 内部文档描述 + * @return 规范 MIME + * @throws Exception 反射调用失败时抛出 + */ + private String mimeType(Object document) throws Exception { + Method method = document.getClass().getDeclaredMethod("mimeType"); + method.setAccessible(true); + return (String) method.invoke(document); + } + + /** + * MIME 兼容性测试用例。 + * + * @param fileName 文件名 + * @param declaredMime 客户端声明 MIME + * @param bytes 文件内容 + * @param canonicalMime 服务端规范 MIME + */ + private record MimeCase(String fileName, + String declaredMime, + byte[] bytes, + String canonicalMime) { + } }