Add path validation to prevent directory traversal attacks
Co-authored-by: zhijiantianya <zhijiantianya@gmail.com>
This commit is contained in:
BIN
test_path_validation.class
Normal file
BIN
test_path_validation.class
Normal file
Binary file not shown.
@@ -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, "表定义已经存在");
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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,
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user