feat: 添加基于 Spring AOP 的排序字段安全校验模块

- 使用 Spring AOP 创建切面类 SortOrderValidationAspect
- 引入 @ValidateSortOrder 注解标记需处理的参数
- 集成 SortWhitelistUtil 白名单工具类
- 支持多种参数类型的排序字段统一校验
- 提升系统安全性,防止非法排序字段注入
This commit is contained in:
xiongxiaoyang
2025-07-17 22:36:23 +08:00
parent a23f4b202e
commit df0c6b84e2
7 changed files with 226 additions and 7 deletions

View File

@ -0,0 +1,18 @@
package io.github.xxyopen.novel.core.annotation;
import java.lang.annotation.*;
/**
* 自定义注解用于标记需要进行排序字段sort和排序方式order校验的方法参数。
* <p>
* 在接收到请求参数时,可通过 AOP 对标注了该注解的参数进行统一处理, 校验并防止 SQL 注入等安全问题。
*
* @author xiongxiaoyang
* @date 2025/7/17
*/
@Target(ElementType.PARAMETER) // 表示该注解只能用于方法参数上
@Retention(RetentionPolicy.RUNTIME) // 注解在运行时依然可用,便于 AOP 或其他框架读取
@Documented // 该注解将被包含在生成的 Javadoc 中
public @interface ValidateSortOrder {
}

View File

@ -0,0 +1,130 @@
package io.github.xxyopen.novel.core.aspect;
import io.github.xxyopen.novel.core.annotation.ValidateSortOrder;
import io.github.xxyopen.novel.core.common.req.PageReqDto;
import io.github.xxyopen.novel.core.common.util.SortWhitelistUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.annotation.Annotation;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Map;
/**
* 排序字段和排序方式的安全校验切面类
* <p>
* 该切面用于拦截所有 Mapper 方法的调用,对带有 @ValidateSortOrder 注解的参数进行统一处理,
* 校验并清理其中的排序字段sort和排序方式order参数防止 SQL 注入攻击。
* <p>
* 支持处理以下类型的参数:
* - PageReqDto 类型对象
* - Map 类型参数
* - 任意带有 sort/order 字段的 POJO 对象
*
* @author xiongxiaoyang
* @date 2025/7/17
*/
@Aspect
@Component
@RequiredArgsConstructor
public class SortOrderValidationAspect {
/**
* 拦截所有 Mapper 方法的调用,检查参数中是否包含 @ValidateSortOrder 注解。
* 如果有,则对参数中的 sort 和 order 字段进行安全校验和清理。
*/
@SneakyThrows
@Around("execution(* io.github.xxyopen.*.dao.mapper.*Mapper.*(..))")
public Object processSortOrderFields(ProceedingJoinPoint joinPoint) {
Object[] args = joinPoint.getArgs();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
// 获取方法参数上的所有注解
Annotation[][] parameterAnnotations = method.getParameterAnnotations();
// 遍历所有参数,检查是否有 @ValidateSortOrder 注解
for (int i = 0; i < parameterAnnotations.length; i++) {
boolean hasAnnotation = Arrays.stream(parameterAnnotations[i])
.anyMatch(a -> a.annotationType().equals(ValidateSortOrder.class));
if (hasAnnotation && args[i] != null) {
// 对带注解的参数进行处理
handleAnnotatedParameter(args[i]);
}
}
// 继续执行原方法
return joinPoint.proceed(args);
}
/**
* 根据参数类型,分别处理不同形式的 sort/order 字段。
*/
@SneakyThrows
private void handleAnnotatedParameter(Object obj) {
if (obj instanceof PageReqDto dto) {
processPageReqDto(dto);
} else if (obj instanceof Map<?, ?> map) {
processMap(map);
} else {
processGenericObject(obj);
}
}
/**
* 处理 PageReqDto 类型参数中的 sort 和 order 字段。
*/
private void processPageReqDto(PageReqDto dto) {
if (dto.getSort() != null) {
dto.setSort(SortWhitelistUtil.sanitizeColumn(dto.getSort()));
}
if (dto.getOrder() != null) {
dto.setOrder(SortWhitelistUtil.sanitizeOrder(dto.getOrder()));
}
}
/**
* 处理 Map 类型参数中的 sort 和 order 字段。
*/
private void processMap(Map map) {
if (map.get("sort") instanceof String sortStr) {
map.put("sort", SortWhitelistUtil.sanitizeColumn(sortStr));
}
if (map.get("order") instanceof String orderStr) {
map.put("order", SortWhitelistUtil.sanitizeOrder(orderStr));
}
}
/**
* 使用反射处理任意对象中的 sort 和 order 字段。
* 支持任何带有这两个字段的 POJO。
*/
@SneakyThrows
private void processGenericObject(Object obj) {
for (Field field : obj.getClass().getDeclaredFields()) {
switch (field.getName()) {
case "sort", "order" -> {
field.setAccessible(true);
Object value = field.get(obj);
if (value instanceof String strValue) {
String sanitized = "sort".equals(field.getName())
? SortWhitelistUtil.sanitizeColumn(strValue)
: SortWhitelistUtil.sanitizeOrder(strValue);
field.set(obj, sanitized);
}
}
default -> {
// 忽略其他字段
}
}
}
}
}

View File

@ -30,4 +30,16 @@ public class PageReqDto {
@Parameter(hidden = true) @Parameter(hidden = true)
private boolean fetchAll = false; private boolean fetchAll = false;
/**
* 排序字段
*/
@Parameter(description = "排序字段")
private String sort;
/**
* 排序方式
*/
@Parameter(description = "排序方式")
private String order;
} }

View File

@ -0,0 +1,63 @@
package io.github.xxyopen.novel.core.common.util;
import lombok.experimental.UtilityClass;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
/**
* 排序字段和排序方式的安全校验工具类
* <p>
* 用于对请求参数中的排序字段sort和排序方式order进行白名单校验
* 防止 SQL 注入攻击,确保传入的字段名和排序方式是系统允许的合法值。
* <p>
* 该工具类使用 Lombok 的 @UtilityClass 注解,确保:
* - 无法被实例化
* - 所有方法为静态方法
*
* @author xiongxiaoyang
* @date 2025/7/17
*/
@UtilityClass
public class SortWhitelistUtil {
/**
* 允许的排序字段白名单集合
* 包含系统中允许作为排序依据的数据库字段名。
*/
private final Set<String> allowedColumns = new HashSet<>(
Arrays.asList( "last_chapter_update_time", "word_count", "visit_count"));
/**
* 允许的排序方式白名单集合
* 支持升序asc和降序desc两种排序方式。
*/
private final Set<String> allowedOrders = new HashSet<>(Arrays.asList("asc", "desc"));
/**
* 校验并清理排序字段
* <p>
* 如果输入的字段在白名单中,则返回小写形式;
* 否则返回默认字段 "id"。
*
* @param input 用户输入的排序字段
* @return 安全的排序字段名
*/
public String sanitizeColumn(String input) {
return allowedColumns.contains(input.toLowerCase()) ? input.toLowerCase() : "id";
}
/**
* 校验并清理排序方式
* <p>
* 如果输入的排序方式是 "asc" 或 "desc",则返回其小写形式;
* 否则返回默认排序方式 "asc"。
*
* @param input 用户输入的排序方式
* @return 安全的排序方式asc 或 desc
*/
public String sanitizeOrder(String input) {
return allowedOrders.contains(input.toLowerCase()) ? input.toLowerCase() : "asc";
}
}

View File

@ -1,6 +1,7 @@
package io.github.xxyopen.novel.dao.mapper; package io.github.xxyopen.novel.dao.mapper;
import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.IPage;
import io.github.xxyopen.novel.core.annotation.ValidateSortOrder;
import io.github.xxyopen.novel.dao.entity.BookInfo; import io.github.xxyopen.novel.dao.entity.BookInfo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import io.github.xxyopen.novel.dto.req.BookSearchReqDto; import io.github.xxyopen.novel.dto.req.BookSearchReqDto;
@ -32,6 +33,6 @@ public interface BookInfoMapper extends BaseMapper<BookInfo> {
* @param condition 搜索条件 * @param condition 搜索条件
* @return 返回结果 * @return 返回结果
* */ * */
List<BookInfo> searchBooks(IPage<BookInfoRespDto> page, BookSearchReqDto condition); List<BookInfo> searchBooks(IPage<BookInfoRespDto> page, @ValidateSortOrder BookSearchReqDto condition);
} }

View File

@ -70,9 +70,4 @@ public class BookSearchReqDto extends PageReqDto {
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
private Date updateTimeMin; private Date updateTimeMin;
/**
* 排序字段
*/
@Parameter(description = "排序字段")
private String sort;
} }

View File

@ -32,7 +32,7 @@
and last_chapter_update_time >= #{condition.updateTimeMin} and last_chapter_update_time >= #{condition.updateTimeMin}
</if> </if>
<if test="condition.sort != null"> <if test="condition.sort != null">
order by ${condition.sort} order by ${condition.sort} desc
</if> </if>
</select> </select>