diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java index 06dff7c..dbdbd9c 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/vo/file/FileUploadReqVO.java @@ -1,6 +1,6 @@ package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file; -import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; @@ -26,10 +26,7 @@ public class FileUploadReqVO { } public static boolean isDirectoryValid(String directory) { - // 1. 不能包含 .. 防止目录穿越 - // 2. 不能以 / 或 \ 开头,防止上传到根目录 - return !StrUtil.contains(directory, "..") - && !StrUtil.startWithAny(directory, "/", "\\"); + return FilePathUtils.isDirectoryValid(directory); } } 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 c3e14a8..d3d60b3 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 @@ -36,7 +36,7 @@ public class AppFileController { @Parameter(name = "file", description = "文件附件", required = true, schema = @Schema(type = "string", format = "binary")) @PermitAll - public CommonResult uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception { + public CommonResult uploadFile(@Valid AppFileUploadReqVO uploadReqVO) throws Exception { MultipartFile file = uploadReqVO.getFile(); byte[] content = IoUtil.readBytes(file.getInputStream()); return success(fileService.createFile(content, file.getOriginalFilename(), 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 2233f35..7cf23c8 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 @@ -33,6 +33,7 @@ public interface ErrorCodeConstants { ErrorCode FILE_PATH_EXISTS = new ErrorCode(1_001_003_000, "文件路径已存在"); 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, "文件路径不正确"); // ========== 代码生成器 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/framework/file/core/client/local/LocalFileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java index 6e5c022..51bea83 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/local/LocalFileClient.java @@ -3,8 +3,12 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.local; import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.IORuntimeException; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; +import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils; -import java.io.File; +import java.nio.file.Path; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID; /** * 本地文件客户端 @@ -50,7 +54,13 @@ public class LocalFileClient extends AbstractFileClient { } private String getFilePath(String path) { - return config.getBasePath() + File.separator + path; + FilePathUtils.validatePath(path); + Path basePath = Path.of(config.getBasePath()).toAbsolutePath().normalize(); + Path filePath = basePath.resolve(path).normalize(); + if (!filePath.startsWith(basePath)) { + throw exception(FILE_PATH_INVALID); + } + return filePath.toString(); } } diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FilePathUtils.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FilePathUtils.java new file mode 100644 index 0000000..4408480 --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/FilePathUtils.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.utils; + +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.StrUtil; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID; + +/** + * 文件路径工具类 + * + * @author 芋道源码 + */ +public class FilePathUtils { + + private FilePathUtils() { + } + + /** + * 校验文件名是否合法,禁止携带目录路径。 + * + * @param name 文件名 + * @return 文件名 + */ + public static String validateFileName(String name) { + if (StrUtil.isEmpty(name)) { + return name; + } + if (!isPathValid(name) || StrUtil.contains(name, StrUtil.SLASH) || !StrUtil.equals(name, FileUtil.getName(name))) { + throw exception(FILE_PATH_INVALID); + } + return name; + } + + /** + * 校验文件目录是否合法。 + * + * @param directory 文件目录,允许为空 + * @return 是否合法 + */ + public static boolean isDirectoryValid(String directory) { + return StrUtil.isEmpty(directory) || isPathValid(directory); + } + + /** + * 校验文件目录是否合法,不合法时抛出业务异常。 + * + * @param directory 文件目录,允许为空 + */ + public static void validateDirectory(String directory) { + if (!isDirectoryValid(directory)) { + throw exception(FILE_PATH_INVALID); + } + } + + /** + * 校验文件相对路径是否合法,不合法时抛出业务异常。 + * + * @param path 文件相对路径 + */ + public static void validatePath(String path) { + if (StrUtil.isEmpty(path) || !isPathValid(path)) { + throw exception(FILE_PATH_INVALID); + } + } + + /** + * 校验路径是否为安全的相对路径,禁止绝对路径、Windows 盘符、反斜杠、空路径段和目录穿越。 + * + * @param path 路径 + * @return 是否合法 + */ + private static boolean isPathValid(String path) { + // 不能以 / 或 \ 开头,避免传入绝对路径 + if (StrUtil.startWithAny(path, StrUtil.SLASH, "\\")) { + return false; + } + // 不能包含反斜杠或空字符,避免绕过不同系统的路径解析 + if (StrUtil.contains(path, "\\") || path.indexOf('\0') >= 0) { + return false; + } + // 不能是 Windows 盘符路径,例如 C:/test.jpg + if (path.length() >= 2 && Character.isLetter(path.charAt(0)) && path.charAt(1) == ':') { + return false; + } + try { + // 使用 JDK Path 再兜底判断一次绝对路径 + if (Path.of(path).isAbsolute()) { + return false; + } + } catch (InvalidPathException ex) { + return false; + } + // 不能包含空路径段、当前目录或上级目录,避免目录穿越 + for (String segment : path.split(StrUtil.SLASH, -1)) { + if (StrUtil.isEmpty(segment) || ".".equals(segment) || "..".equals(segment)) { + return false; + } + } + return true; + } + +} 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 639c689..a93c07d 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 @@ -15,6 +15,7 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned 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 cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; @@ -70,11 +71,14 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows public String createFile(byte[] content, String name, String directory, String type) { - // 1.1 处理 type 为空的情况 + // 1.1 处理 name 的合法性,禁止携带目录路径 + name = FilePathUtils.validateFileName(name); + + // 1.2.1 处理 type 为空的情况 if (StrUtil.isEmpty(type)) { type = FileTypeUtils.getMineType(content, name); } - // 1.2 处理 name 为空的情况 + // 1.2.2 处理 name 为空的情况 if (StrUtil.isEmpty(name)) { name = DigestUtil.sha256Hex(content); } @@ -102,7 +106,11 @@ public class FileServiceImpl implements FileService { @VisibleForTesting String generateUploadPath(String name, String directory) { - // 1. 生成前缀、后缀 + // 1.1 处理 name 和 directory 的合法性 + name = FilePathUtils.validateFileName(name); + FilePathUtils.validatePath(name); + FilePathUtils.validateDirectory(directory); + // 1.2 生成前缀、后缀 String prefix = null; if (PATH_PREFIX_DATE_ENABLE) { prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); @@ -159,7 +167,13 @@ public class FileServiceImpl implements FileService { @Override public Long createFile(FileCreateReqVO createReqVO) { + // 1.1 校验参数的合法性 + FilePathUtils.validatePath(createReqVO.getPath()); + createReqVO.setName(FilePathUtils.validateFileName(createReqVO.getName())); + // 1.2 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性 createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数 + + // 2. 保存到数据库 FileDO file = BeanUtils.toBean(createReqVO, FileDO.class); fileMapper.insert(file); return file.getId(); @@ -172,15 +186,17 @@ public class FileServiceImpl implements FileService { @Override public void deleteFile(Long id) throws Exception { - // 校验存在 + // 1.1 校验存在 FileDO file = validateFileExists(id); + // 1.2 校验路径合法性,避免误删文件存储器中的其他文件 + FilePathUtils.validatePath(file.getPath()); - // 从文件存储器中删除 + // 2.1 从文件存储器中删除 FileClient client = fileConfigService.getFileClient(file.getConfigId()); Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId()); client.delete(file.getPath()); - // 删除记录 + // 2.2 删除记录 fileMapper.deleteById(id); } @@ -190,6 +206,7 @@ public class FileServiceImpl implements FileService { // 删除文件 List files = fileMapper.selectByIds(ids); for (FileDO file : files) { + FilePathUtils.validatePath(file.getPath()); // 获取客户端 FileClient client = fileConfigService.getFileClient(file.getConfigId()); Assert.notNull(client, "客户端({}) 不能为空", file.getPath()); @@ -211,8 +228,13 @@ public class FileServiceImpl implements FileService { @Override public byte[] getFileContent(Long configId, String path) throws Exception { + // 1. 校验路径合法性 + FilePathUtils.validatePath(path); + + // 2.1 获取客户端 FileClient client = fileConfigService.getFileClient(configId); Assert.notNull(client, "客户端({}) 不能为空", configId); + // 2.2 获取文件内容 return client.getContent(path); } diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java index 6bc8c7b..1164afd 100644 --- a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/local/LocalFileClientTest.java @@ -1,16 +1,57 @@ package cn.iocoder.yudao.module.infra.framework.file.core.local; +import cn.hutool.core.io.FileUtil; import cn.hutool.core.io.resource.ResourceUtil; import cn.hutool.core.util.IdUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClient; import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.charset.StandardCharsets; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString; +import static org.junit.jupiter.api.Assertions.*; public class LocalFileClientTest { + @TempDir + public File tempDir; + + @Test + public void testUpload_success() { + // 准备参数 + LocalFileClient client = createClient(); + byte[] content = "test".getBytes(StandardCharsets.UTF_8); + String path = "avatar/test.txt"; + + // 调用 + String url = client.upload(content, path, "text/plain"); + + // 断言 + assertEquals("http://127.0.0.1:48080/admin-api/infra/file/0/get/avatar/test.txt", url); + assertArrayEquals(content, FileUtil.readBytes(new File(tempDir, path))); + assertArrayEquals(content, client.getContent(path)); + + // 删除 + client.delete(path); + assertFalse(FileUtil.exist(new File(tempDir, path))); + } + + @Test + public void testUpload_pathInvalid() { + // 准备参数 + LocalFileClient client = createClient(); + byte[] content = "test".getBytes(StandardCharsets.UTF_8); + + // 调用,并断言异常 + assertThrows(ServiceException.class, () -> client.upload(content, "../test.txt", "text/plain")); + assertFalse(FileUtil.exist(new File(tempDir.getParentFile(), "test.txt"))); + } + @Test @Disabled public void test() { @@ -42,4 +83,13 @@ public class LocalFileClientTest { System.out.println(); } + private LocalFileClient createClient() { + LocalFileClientConfig config = new LocalFileClientConfig(); + config.setDomain("http://127.0.0.1:48080"); + config.setBasePath(tempDir.getAbsolutePath()); + LocalFileClient client = new LocalFileClient(0L, config); + client.init(); + return client; + } + } 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 8cd8fe7..60c0b0c 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 @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; 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.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; @@ -22,6 +23,7 @@ import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.bui 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 org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.*; @@ -173,6 +175,16 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testDeleteFile_pathInvalid() { + // mock 数据 + FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("../tudou.jpg")); + fileMapper.insert(dbFile); + + // 调用,并断言异常 + assertServiceException(() -> fileService.deleteFile(dbFile.getId()), FILE_PATH_INVALID); + } + + @Test public void testGetFileContent() throws Exception { // 准备参数 Long configId = 10L; @@ -190,6 +202,59 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testGetFileContent_pathInvalid() { + // 准备参数 + Long configId = 10L; + String path = "../tudou.jpg"; + + // 调用,并断言异常 + assertServiceException(() -> fileService.getFileContent(configId, path), FILE_PATH_INVALID); + } + + @Test + public void testCreateFileByPresignedPath_success() { + // 准备参数 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setPath("avatar/test.jpg"); + o.setName("test.jpg"); + o.setUrl("https://www.iocoder.cn/test.jpg?token=123"); + }); + + // 调用 + Long fileId = fileService.createFile(reqVO); + + // 断言 + FileDO file = fileMapper.selectById(fileId); + assertEquals("avatar/test.jpg", file.getPath()); + assertEquals("test.jpg", file.getName()); + assertEquals("https://www.iocoder.cn/test.jpg", file.getUrl()); + } + + @Test + public void testCreateFileByPresignedPath_nameInvalid() { + // 准备参数 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setPath("avatar/test.jpg"); + o.setName("../test.jpg"); + }); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(reqVO), FILE_PATH_INVALID); + } + + @Test + public void testCreateFileByPresignedPath_pathInvalid() { + // 准备参数 + FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> { + o.setPath("../test.jpg"); + o.setName("test.jpg"); + }); + + // 调用,并断言异常 + assertServiceException(() -> fileService.createFile(reqVO), FILE_PATH_INVALID); + } + + @Test public void testGenerateUploadPath_AllEnabled() { // 准备参数 String name = "test.jpg"; @@ -343,6 +408,28 @@ public class FileServiceImplTest extends BaseDbUnitTest { } @Test + public void testGenerateUploadPath_FileNameInvalid() { + // 准备参数 + String name = "../test.jpg"; + String directory = "avatar"; + FileServiceImpl.PATH_PREFIX_DATE_ENABLE = false; + FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = false; + + // 调用,并断言异常 + assertServiceException(() -> fileService.generateUploadPath(name, directory), FILE_PATH_INVALID); + } + + @Test + public void testGenerateUploadPath_DirectoryInvalid() { + // 准备参数 + String name = "test.jpg"; + String directory = "../avatar"; + + // 调用,并断言异常 + assertServiceException(() -> fileService.generateUploadPath(name, directory), FILE_PATH_INVALID); + } + + @Test public void testGenerateUploadPath_DirectoryEmpty() { // 准备参数 String name = "test.jpg";