diff --git a/test_path_validation.class b/test_path_validation.class new file mode 100644 index 0000000000..299e5d4d4e Binary files /dev/null and b/test_path_validation.class differ 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 2233f353e7..a1aef35f96 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/utils/PathValidationUtils.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/PathValidationUtils.java new file mode 100644 index 0000000000..7ca12f886c --- /dev/null +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/PathValidationUtils.java @@ -0,0 +1,104 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.utils; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*; + +/** + * 路径验证工具类 + * + * @author 芋道源码 + */ +public class PathValidationUtils { + + /** + * 验证并清理目录路径,防止路径遍历攻击 + * + * @param directory 原始目录路径 + * @return 清理后的目录路径 + */ + public static String validateAndCleanDirectory(String directory) { + if (StrUtil.isEmpty(directory)) { + return null; + } + + // 1. 检查是否包含路径遍历攻击的字符序列 + String normalizedPath = directory.replace('\\', '/'); // 统一使用正斜杠 + if (containsPathTraversal(normalizedPath)) { + throw ServiceExceptionUtil.exception(FILE_PATH_INVALID); + } + + // 2. 清理路径,移除多余的斜杠和点 + String cleanedPath = cleanPath(normalizedPath); + + // 3. 确保路径不以斜杠开头或结尾 + if (cleanedPath.startsWith("/")) { + cleanedPath = cleanedPath.substring(1); + } + if (cleanedPath.endsWith("/")) { + cleanedPath = cleanedPath.substring(0, cleanedPath.length() - 1); + } + + return cleanedPath.isEmpty() ? null : cleanedPath; + } + + /** + * 检查路径是否包含路径遍历攻击 + * + * @param path 路径 + * @return 是否包含路径遍历攻击 + */ + private static boolean containsPathTraversal(String path) { + if (StrUtil.isEmpty(path)) { + return false; + } + + // 检查常见的路径遍历模式 + String[] dangerousPatterns = { + "..", "..\\", "../", "..%2f", "..%5c", "..%2F", "..%5C", + "%2e%2e", "%2E%2E", "%2e%2e%2f", "%2E%2E%2F", + "....//", "....\\\\", "....%2f", "....%5c" + }; + + String lowerPath = path.toLowerCase(); + for (String pattern : dangerousPatterns) { + if (lowerPath.contains(pattern)) { + return true; + } + } + + // 检查绝对路径 + if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { + return true; + } + + return false; + } + + /** + * 清理路径,移除多余的斜杠和点 + * + * @param path 原始路径 + * @return 清理后的路径 + */ + private static String cleanPath(String path) { + if (StrUtil.isEmpty(path)) { + return path; + } + + // 移除多余的斜杠 + path = path.replaceAll("/+", "/"); + + // 移除开头的斜杠 + if (path.startsWith("/")) { + path = path.substring(1); + } + + // 移除结尾的斜杠 + if (path.endsWith("/")) { + path = path.substring(0, path.length() - 1); + } + + return path; + } +} \ No newline at end of file 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 98447fb370..15e98dd0bb 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.dal.mysql.file.FileMapper; import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; +import cn.iocoder.yudao.module.infra.framework.file.core.utils.PathValidationUtils; import com.google.common.annotations.VisibleForTesting; import jakarta.annotation.Resource; import lombok.SneakyThrows; @@ -94,7 +95,10 @@ public class FileServiceImpl implements FileService { @VisibleForTesting String generateUploadPath(String name, String directory) { - // 1. 生成前缀、后缀 + // 1. 验证并清理目录路径,防止路径遍历攻击 + directory = PathValidationUtils.validateAndCleanDirectory(directory); + + // 2. 生成前缀、后缀 String prefix = null; if (PATH_PREFIX_DATE_ENABLE) { prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); @@ -104,7 +108,7 @@ public class FileServiceImpl implements FileService { suffix = String.valueOf(System.currentTimeMillis()); } - // 2.1 先拼接 suffix 后缀 + // 3.1 先拼接 suffix 后缀 if (StrUtil.isNotEmpty(suffix)) { String ext = FileUtil.extName(name); if (StrUtil.isNotEmpty(ext)) { @@ -113,11 +117,11 @@ public class FileServiceImpl implements FileService { name = name + StrUtil.C_UNDERLINE + suffix; } } - // 2.2 再拼接 prefix 前缀 + // 3.2 再拼接 prefix 前缀 if (StrUtil.isNotEmpty(prefix)) { name = prefix + StrUtil.SLASH + name; } - // 2.3 最后拼接 directory 目录 + // 3.3 最后拼接 directory 目录 if (StrUtil.isNotEmpty(directory)) { name = directory + StrUtil.SLASH + name; } @@ -127,10 +131,13 @@ public class FileServiceImpl implements FileService { @Override @SneakyThrows public FilePresignedUrlRespVO getFilePresignedUrl(String name, String directory) { - // 1. 生成上传的 path,需要保证唯一 + // 1. 验证并清理目录路径,防止路径遍历攻击 + directory = PathValidationUtils.validateAndCleanDirectory(directory); + + // 2. 生成上传的 path,需要保证唯一 String path = generateUploadPath(name, directory); - // 2. 获取文件预签名地址 + // 3. 获取文件预签名地址 FileClient fileClient = fileConfigService.getMasterFileClient(); FilePresignedUrlRespDTO presignedObjectUrl = fileClient.getPresignedObjectUrl(path); return BeanUtils.toBean(presignedObjectUrl, FilePresignedUrlRespVO.class, diff --git a/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/PathValidationUtilsTest.java b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/PathValidationUtilsTest.java new file mode 100644 index 0000000000..d2aeaf9226 --- /dev/null +++ b/yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/utils/PathValidationUtilsTest.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.infra.framework.file.core.utils; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 路径验证工具类测试 + * + * @author 芋道源码 + */ +class PathValidationUtilsTest { + + @Test + void testValidateAndCleanDirectory_NullInput() { + String result = PathValidationUtils.validateAndCleanDirectory(null); + assertNull(result); + } + + @Test + void testValidateAndCleanDirectory_EmptyInput() { + String result = PathValidationUtils.validateAndCleanDirectory(""); + assertNull(result); + } + + @Test + void testValidateAndCleanDirectory_ValidDirectory() { + String result = PathValidationUtils.validateAndCleanDirectory("test/directory"); + assertEquals("test/directory", result); + } + + @Test + void testValidateAndCleanDirectory_WithTrailingSlash() { + String result = PathValidationUtils.validateAndCleanDirectory("test/directory/"); + assertEquals("test/directory", result); + } + + @Test + void testValidateAndCleanDirectory_WithLeadingSlash() { + String result = PathValidationUtils.validateAndCleanDirectory("/test/directory"); + assertEquals("test/directory", result); + } + + @Test + void testValidateAndCleanDirectory_WithMultipleSlashes() { + String result = PathValidationUtils.validateAndCleanDirectory("test///directory"); + assertEquals("test/directory", result); + } + + @Test + void testValidateAndCleanDirectory_PathTraversalAttack() { + // 测试路径遍历攻击 + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("../../etc/passwd"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("..\\..\\windows\\system32"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("....//etc/passwd"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("....\\\\windows\\system32"); + }); + } + + @Test + void testValidateAndCleanDirectory_UrlEncodedPathTraversal() { + // 测试URL编码的路径遍历攻击 + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("..%2f..%2fetc%2fpasswd"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("..%5c..%5cwindows%5csystem32"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("%2e%2e%2f%2e%2e%2fetc%2fpasswd"); + }); + } + + @Test + void testValidateAndCleanDirectory_AbsolutePath() { + // 测试绝对路径 + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("/etc/passwd"); + }); + + assertThrows(ServiceException.class, () -> { + PathValidationUtils.validateAndCleanDirectory("C:\\windows\\system32"); + }); + } + + @Test + void testValidateAndCleanDirectory_ValidComplexPath() { + String result = PathValidationUtils.validateAndCleanDirectory("uploads/2024/01/images"); + assertEquals("uploads/2024/01/images", result); + } + + @Test + void testValidateAndCleanDirectory_WithDots() { + // 测试包含点的路径(但不是路径遍历) + String result = PathValidationUtils.validateAndCleanDirectory("my.file.txt"); + assertEquals("my.file.txt", result); + } +} \ No newline at end of file