fix: 兼容客户端文档 MIME 差异
- 接受 WPS及系统 MIME 别名并统一保存服务端标准类型 - 保留扩展名、大小和文件头校验以拒绝伪装文件
This commit is contained in:
@@ -56,7 +56,7 @@ public class AgentDocumentService {
|
|||||||
private static final byte[] OLE_SIGNATURE = {
|
private static final byte[] OLE_SIGNATURE = {
|
||||||
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
|
(byte) 0xd0, (byte) 0xcf, 0x11, (byte) 0xe0, (byte) 0xa1, (byte) 0xb1, 0x1a, (byte) 0xe1
|
||||||
};
|
};
|
||||||
private static final Map<String, Set<String>> MIME_TYPES = mimeTypes();
|
private static final Map<String, String> CANONICAL_MIME_TYPES = canonicalMimeTypes();
|
||||||
|
|
||||||
private final AgentDocumentAttachmentMapper attachmentMapper;
|
private final AgentDocumentAttachmentMapper attachmentMapper;
|
||||||
private final AgentDocumentSnapshotMapper snapshotMapper;
|
private final AgentDocumentSnapshotMapper snapshotMapper;
|
||||||
@@ -701,8 +701,8 @@ public class AgentDocumentService {
|
|||||||
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE,
|
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE,
|
||||||
"该类型文档不能超过 " + limit / (1024 * 1024) + " MiB");
|
"该类型文档不能超过 " + limit / (1024 * 1024) + " MiB");
|
||||||
}
|
}
|
||||||
String mimeType = normalizeMime(file.getContentType(), extension);
|
|
||||||
validateSignature(file, extension);
|
validateSignature(file, extension);
|
||||||
|
String mimeType = normalizeMime(file.getContentType(), extension);
|
||||||
return new DocumentFile(originalName, extension, mimeType, file.getSize());
|
return new DocumentFile(originalName, extension, mimeType, file.getSize());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,15 +768,27 @@ public class AgentDocumentService {
|
|||||||
return properties.getLimits().getOfficeMaxBytes().toBytes();
|
return properties.getLimits().getOfficeMaxBytes().toBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将客户端声明的 MIME 规范为服务端可信类型。
|
||||||
|
*
|
||||||
|
* @param declared 客户端声明 MIME
|
||||||
|
* @param extension 已校验扩展名
|
||||||
|
* @return 服务端规范 MIME
|
||||||
|
*/
|
||||||
private String normalizeMime(String declared, String extension) {
|
private String normalizeMime(String declared, String extension) {
|
||||||
String normalized = declared == null ? "" : declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
String normalized = declared == null ? "" : declared.split(";", 2)[0].trim().toLowerCase(Locale.ROOT);
|
||||||
Set<String> allowed = MIME_TYPES.get(extension);
|
String canonical = CANONICAL_MIME_TYPES.get(extension);
|
||||||
if (StringUtils.hasText(normalized) && !"application/octet-stream".equals(normalized)
|
if (!StringUtils.hasText(canonical)) {
|
||||||
&& (allowed == null || !allowed.contains(normalized))) {
|
throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "不支持该文档类型");
|
||||||
throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
"文件 MIME 类型与扩展名不一致");
|
|
||||||
}
|
}
|
||||||
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) {
|
private String safeOriginalName(String value) {
|
||||||
@@ -904,17 +916,22 @@ public class AgentDocumentService {
|
|||||||
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
return new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, Set<String>> mimeTypes() {
|
/**
|
||||||
Map<String, Set<String>> values = new LinkedHashMap<>();
|
* 创建受支持扩展名到服务端规范 MIME 的映射。
|
||||||
values.put("pdf", Set.of("application/pdf"));
|
*
|
||||||
values.put("doc", Set.of("application/msword"));
|
* @return 不可变规范 MIME 映射
|
||||||
values.put("docx", Set.of("application/vnd.openxmlformats-officedocument.wordprocessingml.document"));
|
*/
|
||||||
values.put("ppt", Set.of("application/vnd.ms-powerpoint"));
|
private static Map<String, String> canonicalMimeTypes() {
|
||||||
values.put("pptx", Set.of("application/vnd.openxmlformats-officedocument.presentationml.presentation"));
|
Map<String, String> values = new LinkedHashMap<>();
|
||||||
values.put("xls", Set.of("application/vnd.ms-excel"));
|
values.put("pdf", "application/pdf");
|
||||||
values.put("xlsx", Set.of("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
values.put("doc", "application/msword");
|
||||||
values.put("txt", Set.of("text/plain"));
|
values.put("docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
|
||||||
values.put("md", Set.of("text/markdown", "text/plain"));
|
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);
|
return Map.copyOf(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import org.junit.Before;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.mockito.Mockito;
|
import org.mockito.Mockito;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import tech.easyflow.agent.config.AgentDocumentProperties;
|
import tech.easyflow.agent.config.AgentDocumentProperties;
|
||||||
import tech.easyflow.agent.entity.AgentDocumentAttachment;
|
import tech.easyflow.agent.entity.AgentDocumentAttachment;
|
||||||
import tech.easyflow.agent.mapper.AgentDocumentAttachmentMapper;
|
import tech.easyflow.agent.mapper.AgentDocumentAttachmentMapper;
|
||||||
@@ -14,13 +15,22 @@ import tech.easyflow.agent.runtime.media.AgentMediaObjectStorage;
|
|||||||
import tech.easyflow.common.entity.LoginAccount;
|
import tech.easyflow.common.entity.LoginAccount;
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.lang.reflect.InvocationTargetException;
|
||||||
|
import java.lang.reflect.Method;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@link AgentDocumentService} 上传幂等性测试。
|
* {@link AgentDocumentService} 上传幂等性测试。
|
||||||
*/
|
*/
|
||||||
public class AgentDocumentServiceTest {
|
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 AgentDocumentAttachmentMapper attachmentMapper;
|
||||||
private AgentMediaObjectStorage objectStorage;
|
private AgentMediaObjectStorage objectStorage;
|
||||||
private AgentDocumentReadTaskProducer taskProducer;
|
private AgentDocumentReadTaskProducer taskProducer;
|
||||||
@@ -82,6 +92,61 @@ public class AgentDocumentServiceTest {
|
|||||||
Mockito.verify(taskProducer, Mockito.never()).send(Mockito.anyString());
|
Mockito.verify(taskProducer, Mockito.never()).send(Mockito.anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 WPS、系统注册表和旧式浏览器 MIME 别名在内容有效时均可上传。
|
||||||
|
*
|
||||||
|
* @throws Exception 反射调用失败时抛出
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void validateFileShouldAcceptClientMimeAliasesAndNormalizeCanonicalType() throws Exception {
|
||||||
|
List<MimeCase> 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());
|
attachment.setStatus(AgentDocumentStatus.READY.name());
|
||||||
return attachment;
|
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) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user