Add path validation to prevent directory traversal attacks

Co-authored-by: zhijiantianya <zhijiantianya@gmail.com>
This commit is contained in:
Cursor Agent
2025-07-09 07:42:52 +00:00
parent e6fecd8efe
commit fc0a9ddaf1
5 changed files with 229 additions and 6 deletions

BIN
test_path_validation.class Normal file

Binary file not shown.

View File

@@ -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, "表定义已经存在");

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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);
}
}