From 03ce4fad5766bd85d318b9d6fb4cf8e43dad92d1 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 26 Sep 2026 18:27:12 +0800 Subject: [PATCH] feat(infra): 增加文件上传类型白名单校验 --- yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java | 1 - yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java | 1 + yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++------ yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-infra/src/test/resources/sql/create_tables.sql | 2 +- 5 files changed, 279 insertions(+), 10 deletions(-) diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java index 77bcea3..0930dc0 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/app/file/AppFileController.java @@ -12,7 +12,6 @@ import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; -import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.validation.annotation.Validated; diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java index 94b89a2..1abdfe3 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants.java @@ -34,6 +34,7 @@ public interface ErrorCodeConstants { ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在"); ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空"); ErrorCode FILE_PATH_INVALID = new ErrorCode(1_001_003_003, "文件路径不正确"); + ErrorCode FILE_TYPE_NOT_ALLOWED = new ErrorCode(1_001_003_004, "不支持的文件类型"); // ========== 代码生成器 1-001-004-000 ========== ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_001_004_002, "表定义已经存在"); diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java index 7fe36cd..218d5b0 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImpl.java @@ -3,6 +3,8 @@ package cn.iocoder.yudao.module.infra.service.file; import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.io.FileUtil; import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.ArrayUtil; +import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.digest.DigestUtil; @@ -23,10 +25,11 @@ import lombok.SneakyThrows; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Set; import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; /** * 文件 Service 实现类 @@ -37,6 +40,29 @@ import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX public class FileServiceImpl implements FileService { /** + * 允许上传的文件类型,可按业务需要调整 + */ + private static final Set ALLOWED_FILE_TYPES = Set.of( + // 图片 + "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", + "image/vnd.microsoft.icon", "image/tiff", "image/avif", "image/heic", "image/heif", "image/svg+xml", + // 文档 + "application/pdf", "text/plain", "text/csv", "text/markdown", "text/html", "application/xhtml+xml", + "application/xml", "application/json", "application/epub+zip", "message/rfc822", "application/vnd.ms-outlook", + "application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + // 压缩包 + "application/zip", "application/x-rar-compressed", "application/x-7z-compressed", "application/gzip", + // 音视频 + "audio/mpeg", "audio/vnd.wave", "audio/vorbis", "audio/ogg", "audio/opus", "audio/mp4", "audio/webm", + "audio/x-aac", "audio/x-flac", "audio/amr", "audio/x-ms-wma", + "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", "video/x-ms-wmv", "video/x-flv", + "application/x-matroska", "video/x-matroska" + ); + + /** * 上传文件的前缀,是否包含日期(yyyyMMdd) * * 目的:按照日期,进行分目录 @@ -74,9 +100,15 @@ public class FileServiceImpl implements FileService { // 1.1 处理 name 的合法性,禁止携带目录路径 name = FilePathUtils.validateFileName(name); - // 1.2.1 处理 type 为空的情况 - if (StrUtil.isEmpty(type)) { - type = FileTypeUtils.getMineType(content, name); + // 1.2.1 校验文件内容和类型,不信任客户端传入的 type + if (ArrayUtil.isEmpty(content)) { + throw exception(FILE_IS_EMPTY); + } + type = FileTypeUtils.getMineType(content, name); + String nameType = StrUtil.isNotEmpty(FileUtil.extName(name)) ? validateFileType(name) : null; + if (!ALLOWED_FILE_TYPES.contains(type) + || (FileTypeUtils.isImage(nameType) && ObjectUtil.notEqual(type, nameType))) { + throw exception(FILE_TYPE_NOT_ALLOWED); } // 1.2.2 处理 name 为空的情况 if (StrUtil.isEmpty(name)) { @@ -148,8 +180,10 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows public FilePresignedUrlRespVO presignPutUrl(String name, String directory) { - // 1. 生成上传的 path,需要保证唯一 + // 1.1 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); + // 1.2 前端直传无法读取文件内容,此处仅校验文件名类型 + validateFileType(name); // 2. 获取文件预签名地址 FileClient fileClient = fileConfigService.getMasterFileClient(); @@ -170,7 +204,13 @@ public class FileServiceImpl implements FileService { // 1.1 校验参数的合法性 FilePathUtils.validatePath(createReqVO.getPath()); createReqVO.setName(FilePathUtils.validateFileName(createReqVO.getName())); - // 1.2 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性 + // 1.2 校验原文件名和存储路径的类型,前端直传不校验文件内容 + String type = validateFileType(createReqVO.getName()); + if (ObjectUtil.notEqual(type, validateFileType(FileUtil.getName(createReqVO.getPath())))) { + throw exception(FILE_TYPE_NOT_ALLOWED); + } + createReqVO.setType(type); + // 1.3 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性 createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 // 2. 保存到数据库 @@ -179,6 +219,14 @@ public class FileServiceImpl implements FileService { return file.getId(); } + private String validateFileType(String name) { + String type = FileTypeUtils.getMineType(name); + if (!ALLOWED_FILE_TYPES.contains(type)) { + throw exception(FILE_TYPE_NOT_ALLOWED); + } + return type; + } + @Override public FileDO getFile(Long id) { return validateFileExists(id); diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java index 4b14aec..8d1eacc 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/file/FileServiceImplTest.java @@ -7,23 +7,31 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.framework.test.core.util.AssertUtils; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO; import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO; +import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; import jakarta.annotation.Resource; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.util.Locale; import java.util.concurrent.atomic.AtomicReference; import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS; -import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.*; @@ -147,6 +155,198 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testCreateFile_success_image() throws Exception { + for (String extension : new String[]{"jpg", "png", "gif", "bmp"}) { + // 准备参数 + String type = "jpg".equals(extension) ? "image/jpeg" : "image/" + extension; + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + assertTrue(ImageIO.write(new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB), extension, outputStream)); + byte[] content = outputStream.toByteArray(); + String name = "头像." + extension.toUpperCase(Locale.ROOT); + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, name, null, "application/octet-stream"); + // 断言 + assertEquals(url, result); + FileDO file = fileMapper.selectOne(FileDO::getUrl, url); + assertEquals(name, file.getName()); + assertEquals(type, file.getType()); + } + } + + @Test + public void testCreateFile_success_excel() throws Exception { + for (String extension : new String[]{"xls", "xlsx"}) { + // 准备参数 + String type = "xls".equals(extension) ? "application/vnd.ms-excel" + : "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + byte[] content; + try (Workbook workbook = "xls".equals(extension) ? new HSSFWorkbook() : new XSSFWorkbook(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + workbook.createSheet("用户").createRow(0).createCell(0).setCellValue("芋道源码"); + workbook.write(outputStream); + content = outputStream.toByteArray(); + } + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, "用户." + extension, null, null); + // 断言 + assertEquals(url, result); + assertEquals(type, fileMapper.selectOne(FileDO::getUrl, url).getType()); + } + } + + @Test + public void testCreateFile_success_detectType() throws Exception { + for (String declaredType : new String[]{null, "", "image/jpg", "text/html", "application/octet-stream"}) { + // 准备参数 + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq("image/jpeg"))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, "头像.jpg", null, declaredType); + // 断言 + assertEquals(url, result); + assertEquals("image/jpeg", fileMapper.selectOne(FileDO::getUrl, url).getType()); + } + } + + @Test + public void testCreateFile_success_document() throws Exception { + for (String[] document : new String[][]{ + {"test.svg", "", "image/svg+xml"}, + {"test.html", "", "text/html"}, + {"test.xml", "test", "application/xml"}, + {"test.markdown", "# test\n\nhello", "text/markdown"}, + {"test.eml", "From: a@example.com\r\nTo: b@example.com\r\nSubject: test\r\n\r\nhello", "message/rfc822"}}) { + // 准备参数 + String name = document[0]; + byte[] content = document[1].getBytes(StandardCharsets.UTF_8); + String type = document[2]; + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq(type))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, name, null, "application/octet-stream"); + // 断言 + assertEquals(url, result); + assertEquals(type, fileMapper.selectOne(FileDO::getUrl, url).getType()); + } + } + + @Test + public void testCreateFile_success_opus() throws Exception { + // 准备参数 + byte[] content = new byte[64]; + System.arraycopy("OggS".getBytes(StandardCharsets.US_ASCII), 0, content, 0, 4); + System.arraycopy("OpusHead".getBytes(StandardCharsets.US_ASCII), 0, content, 28, 8); + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + String url = randomString(); + when(client.upload(same(content), anyString(), eq("audio/opus"))).thenReturn(url); + + // 调用 + String result = fileService.createFile(content, "voice.ogg", null, "audio/ogg;codecs=opus"); + // 断言 + assertEquals(url, result); + assertEquals("audio/opus", fileMapper.selectOne(FileDO::getUrl, url).getType()); + } + + @Test + public void testCreateFile_typeNotAllowed_fakeImage() { + for (String text : new String[]{"", + "", "普通文本"}) { + // 准备参数 + byte[] content = text.getBytes(StandardCharsets.UTF_8); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(content, "头像.jpg", null, "image/jpeg"), + FILE_TYPE_NOT_ALLOWED); + // 断言未上传文件、未保存记录 + verifyNoInteractions(fileConfigService); + assertEquals(0L, fileMapper.selectCount()); + } + } + + @Test + public void testCreateFile_typeNotAllowed_extension() { + for (String name : new String[]{"头像.svg", "头像.js", "头像.exe", "头像.unknown", "头像.png"}) { + // 准备参数 + byte[] content = ResourceUtil.readBytes("file/erweima.jpg"); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(content, name, null, "image/jpeg"), FILE_TYPE_NOT_ALLOWED); + // 校验调用 + verifyNoInteractions(fileConfigService); + } + } + + @Test + public void testCreateFile_fileIsEmpty() { + // 准备参数 + byte[] content = new byte[0]; + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(content, "头像.jpg", null, null), FILE_IS_EMPTY); + // 校验调用 + verifyNoInteractions(fileConfigService); + } + + @Test + public void testPresignPutUrl_success() { + for (String extension : new String[]{"jpg", "JPEG", "jfif", "png", "gif", "webp", "bmp", "ico", "tiff", + "avif", "heic", "heif", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "csv", "md", + "zip", "rar", "mp3", "mp4", "svg", "html", "htm", "xml", "markdown", "eml", "msg", "epub", + "json", "opus", "ogg", "m4a", "webm", "amr", "wma", "wmv", "flv", "mkv"}) { + // 准备参数 + String name = "测试文件." + extension; + // mock 方法 + FileClient client = mock(FileClient.class); + when(fileConfigService.getMasterFileClient()).thenReturn(client); + when(client.getId()).thenReturn(10L); + when(client.presignPutUrl(anyString())).thenReturn("upload-url"); + when(client.presignGetUrl(anyString(), isNull())).thenReturn("visit-url"); + + // 调用 + FilePresignedUrlRespVO result = fileService.presignPutUrl(name, "avatar"); + // 断言 + assertEquals(10L, result.getConfigId()); + assertTrue(result.getPath().endsWith("/" + name)); + assertEquals("upload-url", result.getUploadUrl()); + assertEquals("visit-url", result.getUrl()); + } + } + + @Test + public void testPresignPutUrl_typeNotAllowed() { + // 准备参数 + for (String name : new String[]{"test.php", "test.jsp", "test.js", "test.exe", "test.unknown", "test"}) { + // 调用,并断言异常 + assertServiceException(() -> fileService.presignPutUrl(name, null), FILE_TYPE_NOT_ALLOWED); + // 校验调用 + verifyNoInteractions(fileConfigService); + } + } + + @Test public void testDeleteFile_success() throws Exception { // mock 数据 FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("tudou.jpg")); @@ -245,6 +445,27 @@ public class FileServiceImplTest extends BaseDbUnitTest { assertEquals("avatar/test.jpg", file.getPath()); assertEquals("test.jpg", file.getName()); assertEquals("https://www.iocoder.cn/test.jpg", file.getUrl()); + assertEquals("image/jpeg", file.getType()); + } + + @Test + public void testCreateFileByPresignedPath_typeNotAllowed() { + for (String[] file : new String[][]{{"test.js", "avatar/test.js"}, {"test.jpg", "avatar/test.html"}, + {"test.html", "avatar/test.jpg"}, {"test.jpg", "avatar/test.png"}}) { + // 准备参数 + String name = file[0]; + String path = file[1]; + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setName(name); + o.setPath(path); + o.setType("image/jpeg"); + }); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(reqVO), FILE_TYPE_NOT_ALLOWED); + // 断言未保存记录 + assertEquals(0L, fileMapper.selectCount()); + } } @Test diff --git a/yudao-module-infra/src/test/resources/sql/create_tables.sql b/yudao-module-infra/src/test/resources/sql/create_tables.sql index d4b19c9..2a44670 100644 --- a/yudao-module-infra/src/test/resources/sql/create_tables.sql +++ b/yudao-module-infra/src/test/resources/sql/create_tables.sql @@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS "infra_file" ( "name" varchar(256), "path" varchar(512), "url" varchar(1024), - "type" varchar(63) DEFAULT NULL, + "type" varchar(128) DEFAULT NULL, "size" bigint NOT NULL, "creator" varchar(64) DEFAULT '', "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, -- libgit2 0.21.4