diff --git a/base/src/main/java/com/tinyengine/it/common/utils/SqlIdentifierValidator.java b/base/src/main/java/com/tinyengine/it/common/utils/SqlIdentifierValidator.java new file mode 100644 index 00000000..a9b5738d --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/common/utils/SqlIdentifierValidator.java @@ -0,0 +1,35 @@ +package com.tinyengine.it.common.utils; + +import java.util.List; +import java.util.regex.Pattern; + +public class SqlIdentifierValidator { + + private static final Pattern IDENTIFIER_PATTERN = + Pattern.compile("^[a-zA-Z_][a-zA-Z0-9_]*$"); + + private static final Pattern ORDER_TYPE_PATTERN = + Pattern.compile("^(ASC|DESC)$", Pattern.CASE_INSENSITIVE); + + private SqlIdentifierValidator() { + } + + public static void validate(String identifier) { + if (identifier == null || !IDENTIFIER_PATTERN.matcher(identifier).matches()) { + throw new IllegalArgumentException("Invalid SQL identifier: " + identifier); + } + } + + public static void validateAll(List identifiers) { + if (identifiers == null) { + return; + } + identifiers.forEach(SqlIdentifierValidator::validate); + } + + public static void validateOrderType(String orderType) { + if (orderType == null || !ORDER_TYPE_PATTERN.matcher(orderType).matches()) { + throw new IllegalArgumentException("Invalid order type: " + orderType); + } + } +} diff --git a/base/src/main/java/com/tinyengine/it/dynamic/dto/DynamicQuery.java b/base/src/main/java/com/tinyengine/it/dynamic/dto/DynamicQuery.java index 79608a70..6b754413 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/dto/DynamicQuery.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/dto/DynamicQuery.java @@ -1,5 +1,6 @@ package com.tinyengine.it.dynamic.dto; +import jakarta.validation.constraints.Pattern; import lombok.Data; import java.util.List; @@ -8,12 +9,19 @@ @Data public class DynamicQuery { - private String nameEn; // 表名 - private String nameCh; // 表中文名 - private List fields; // 查询字段 - private Map params; // 查询条件 - private Integer currentPage = 1; // 页码 - private Integer pageSize = 10; // 每页大小 - private String orderBy; // 排序字段 - private String orderType = "ASC"; // 排序方式 + @Pattern(regexp = "^[a-zA-Z_][a-zA-Z0-9_]*$", message = "模型名称格式不正确") + private String nameEn; + private String nameCh; + private List< + @Pattern(regexp = "^[a-zA-Z_][a-zA-Z0-9_]*$", message = "字段名格式不正确") + String> fields; + private Map params; + private Integer currentPage = 1; + private Integer pageSize = 10; + + @Pattern(regexp = "^[a-zA-Z_][a-zA-Z0-9_]*$", message = "排序字段格式不正确") + private String orderBy; + + @Pattern(regexp = "^(?i)(ASC|DESC)$", message = "排序方式只能是 ASC 或 DESC") + private String orderType = "ASC"; } diff --git a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java index c2c2c4cb..3e7cdb94 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java @@ -11,9 +11,11 @@ import com.tinyengine.it.dynamic.dto.DynamicUpdate; import com.tinyengine.it.model.dto.ParametersDto; import com.tinyengine.it.model.entity.Model; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import com.tinyengine.it.common.utils.SqlIdentifierValidator; +import com.tinyengine.it.service.material.ModelService; +import org.springframework.context.annotation.Lazy; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.PreparedStatementCreator; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; @@ -31,12 +33,26 @@ @Service @Slf4j -@RequiredArgsConstructor public class DynamicModelService { + private static final Set SYSTEM_FIELDS = Set.of( + "id", "created_at", "updated_at", "deleted_at", "created_by", "updated_by" + ); + private final JdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; private final LoginUserContext loginUserContext; + private final ModelService modelService; + + public DynamicModelService(JdbcTemplate jdbcTemplate, + NamedParameterJdbcTemplate namedParameterJdbcTemplate, + LoginUserContext loginUserContext, + @Lazy ModelService modelService) { + this.jdbcTemplate = jdbcTemplate; + this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; + this.loginUserContext = loginUserContext; + this.modelService = modelService; + } /** @@ -182,6 +198,17 @@ public List> dynamicQuery(String tableName, String orderBy, Integer limit) { + SqlIdentifierValidator.validate(tableName); + SqlIdentifierValidator.validateAll(fields); + if (conditions != null && !conditions.isEmpty()) { + for (String key : conditions.keySet()) { + SqlIdentifierValidator.validate(key); + } + } + if (orderBy != null && !orderBy.isEmpty()) { + SqlIdentifierValidator.validate(orderBy.replaceAll("(?i)\\s+(ASC|DESC)$", "")); + } + // 1. 构建SQL StringBuilder sql = new StringBuilder("SELECT "); @@ -267,18 +294,19 @@ public Long count(String tableName, Map conditions) { * 分页查询 */ public Map queryWithPage(DynamicQuery dto) { - String tableName = getTableName( dto.getNameEn()); + String tableName = getTableName(dto.getNameEn()); List fields = dto.getFields(); Map conditions = dto.getParams(); String orderBy = dto.getOrderBy(); Integer pageNum = dto.getCurrentPage(); Integer pageSize = dto.getPageSize(); + validateQueryFields(dto); + // 计算分页 Integer limit = null; if (pageNum != null && pageSize != null) { limit = pageSize; - // 如果需要偏移量,可以在这里处理 } // 执行查询 @@ -292,6 +320,60 @@ public Map queryWithPage(DynamicQuery dto) { return result; } + + private Set getAllowedFields(String nameEn) { + List modelList = modelService.getModelByEnName(nameEn); + if (modelList == null || modelList.isEmpty()) { + return Collections.emptySet(); + } + Model model = modelList.get(0); + Set allowed = new HashSet<>(SYSTEM_FIELDS); + if (model.getParameters() != null) { + for (Object param : model.getParameters()) { + String prop = extractProp(param); + if (prop != null) { + allowed.add(prop); + } + } + } + return allowed; + } + + @SuppressWarnings("unchecked") + private String extractProp(Object param) { + if (param instanceof ParametersDto) { + return ((ParametersDto) param).getProp(); + } + if (param instanceof Map) { + Object value = ((Map) param).get("prop"); + return value != null ? value.toString() : null; + } + return null; + } + + private void validateQueryFields(DynamicQuery dto) { + Set allowedFields = getAllowedFields(dto.getNameEn()); + + if (dto.getFields() != null && !dto.getFields().isEmpty()) { + for (String field : dto.getFields()) { + SqlIdentifierValidator.validate(field); + if (!allowedFields.contains(field)) { + throw new IllegalArgumentException("不允许的字段: " + field); + } + } + } + + if (dto.getOrderBy() != null && !dto.getOrderBy().isEmpty()) { + SqlIdentifierValidator.validate(dto.getOrderBy()); + if (!allowedFields.contains(dto.getOrderBy())) { + throw new IllegalArgumentException("不允许的排序字段: " + dto.getOrderBy()); + } + } + + if (dto.getOrderType() != null) { + SqlIdentifierValidator.validateOrderType(dto.getOrderType()); + } + } private Object convertValueByType(Object value, String fieldType, String columnName) { try { switch (fieldType) { @@ -525,6 +607,9 @@ public Map createData(DynamicInsert dataDto) { String tableName = getTableName(dataDto.getNameEn()); Map record = new HashMap<>(dataDto.getParams()); + for (String col : record.keySet()) { + SqlIdentifierValidator.validate(col); + } String userId = loginUserContext.getLoginUserId(); // 添加系统字段 record.put("created_by",userId); @@ -606,6 +691,9 @@ public Map updateDateById(DynamicUpdate dto) { } Long id = Long.parseLong(params1.get("id").toString()); Map updateFields = dto.getData(); + for (String col : updateFields.keySet()) { + SqlIdentifierValidator.validate(col); + } String tableName = getTableName(modelId); StringBuilder sql = new StringBuilder("UPDATE " + tableName + " SET "); List params = new ArrayList<>(); diff --git a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java index 27ba63c3..f33cb184 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java @@ -2,8 +2,10 @@ import com.alibaba.fastjson.JSONObject; import com.tinyengine.it.common.context.LoginUserContext; +import com.tinyengine.it.common.utils.SqlIdentifierValidator; import com.tinyengine.it.dynamic.dao.ModelDataDao; import com.tinyengine.it.dynamic.dto.*; +import com.tinyengine.it.model.dto.ParametersDto; import com.tinyengine.it.model.entity.Model; import com.tinyengine.it.service.material.ModelService; import jakarta.transaction.Transactional; @@ -12,232 +14,248 @@ import java.math.BigInteger; import java.util.*; + @Service public class DynamicService { - @Autowired - private ModelDataDao dynamicDao; - @Autowired - private ModelService modelService; @Autowired - private LoginUserContext loginUserContext; - - - // 操作类型常量 - private static final String OPERATION_SELECT = "SELECT"; - private static final String OPERATION_INSERT = "INSERT"; - private static final String OPERATION_UPDATE = "UPDATE"; - private static final String OPERATION_DELETE = "DELETE"; - - /** - * 查询数据 - * @param dto - * @return list - */ - public List query(DynamicQuery dto) { - String tableName = getTableName(dto.getNameEn()); - Map params = new HashMap<>(); - params.put("tableName", tableName); - params.put("fields", dto.getFields()); - params.put("conditions", dto.getParams()); - params.put("pageNum", dto.getCurrentPage()); - params.put("pageSize", dto.getPageSize()); - params.put("orderBy", dto.getOrderBy()); - params.put("orderType", dto.getOrderType()); - - return dynamicDao.select(params); - } - - /** - * 统计数量 - * @param tableName - * @param conditions - * @return long - */ - public Long count(String tableName, Map conditions) { - Map params = new HashMap<>(); - params.put("tableName", tableName); - params.put("fields", Arrays.asList("COUNT(*) as count")); - params.put("conditions", conditions); - - List result = dynamicDao.select(params); - return Long.parseLong(result.get(0).get("count").toString()); - } - - /** - * 分页查询 - * @param dto - * @return map - */ - public Map queryWithPage(DynamicQuery dto) { - if( dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { - throw new IllegalArgumentException("查询操作必须指定模型名称"); - } - if( dto.getCurrentPage() == null || dto.getCurrentPage() <= 0) { - dto.setCurrentPage(1); - } - if( dto.getPageSize() == null || dto.getPageSize() <= 0) { - dto.setPageSize(10); - } - validateTableExists(dto.getNameEn()); - validateTableAndData(dto.getNameEn(), dto.getParams()); - List list = query(dto); - String tableName = getTableName(dto.getNameEn()); - Long total = count(tableName, dto.getParams()); - - Map result = new HashMap<>(); - result.put("list", list); - result.put("total", total); - result.put("pageNum", dto.getCurrentPage()); - result.put("pageSize", dto.getPageSize()); - result.put("pages", (int) Math.ceil((double) total / dto.getPageSize())); - - return result; - } - - /** - * 插入数据 - * @param dto - * @return map - */ - @Transactional - public Map insert(DynamicInsert dto) { - if( dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { - throw new IllegalArgumentException("插入操作必须指定模型名称"); - } - if( dto.getParams() == null || dto.getParams().isEmpty()) { - throw new IllegalArgumentException("插入数据不能为空"); - } - validateTableExists(dto.getNameEn()); - validateTableAndData(dto.getNameEn(), dto.getParams()); - String tableName = getTableName(dto.getNameEn()); - Map params = new HashMap<>(); - params.put("tableName", tableName); - params.put("data", dto.getParams()); - - - String userId = loginUserContext.getLoginUserId(); - if( userId == null || userId.trim().isEmpty()) { - List modelList = modelService.getModelByEnName(dto.getNameEn()); - if( modelList.isEmpty()) { - throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); - }else { - userId=modelList.get(0).getCreatedBy(); - } - } - - // 添加系统字段 - dto.getParams().put("created_by", userId); - dto.getParams().put("updated_by", userId); - - Map result = new HashMap<>(); - Long insertRow = dynamicDao.insert(params); - BigInteger id = (BigInteger) params.get("id"); - result.put("insert", insertRow); - result.put("id", id.longValue()); - return result; - } - - - /** - * 更新数据 - * @param dto - * @return - */ - @Transactional - public Map update(DynamicUpdate dto) { - if( dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { - throw new IllegalArgumentException("更新操作必须指定模型名称"); - } - if (dto.getParams() == null || dto.getParams().isEmpty()) { - throw new IllegalArgumentException("更新操作必须指定条件"); - } - if( dto.getData() == null || dto.getData().isEmpty()) { - throw new IllegalArgumentException("更新数据不能为空"); - } - validateTableExists(dto.getNameEn()); - validateTableAndData(dto.getNameEn(), dto.getData()); - String tableName = getTableName(dto.getNameEn()); - Map params = new HashMap<>(); - params.put("tableName", tableName); - params.put("data", dto.getData()); - params.put("conditions", dto.getParams()); - Map result = new HashMap<>(); - Integer update = dynamicDao.update(params); - result.put("update", update); - return result; - } - - /** - * 删除数据 - */ - @Transactional - public Map delete(DynamicDelete dto) { - if( dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { - throw new IllegalArgumentException("删除操作必须指定模型名称"); - } - if (dto.getId() == null ) { - throw new IllegalArgumentException("删除操作必须指定id"); - } - validateTableExists(dto.getNameEn()); - String tableName = getTableName(dto.getNameEn()); - - Map params = new HashMap<>(); - Map conditions = new HashMap<>(); - conditions.put("id", dto.getId()); - params.put("tableName", tableName); - params.put("conditions",conditions); - Map result = new HashMap<>(); - Integer delete = dynamicDao.delete(params); - result.put("delete", delete); - return result; - } - - - - /** - * 获取表结构 - */ - public List> getTableStructure(String tableName) { - validateTableExists(tableName); - return dynamicDao.getTableStructure(tableName); - } - - /** - * 验证表和数据 - */ - private void validateTableAndData(String tableName, Map data) { - if (tableName == null || tableName.trim().isEmpty()) { - throw new IllegalArgumentException("表名不能为空"); - } - - // 防止SQL注入,验证表名格式 - if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { - throw new IllegalArgumentException("表名格式不正确"); - } - - if (data == null || data.isEmpty()) { - throw new IllegalArgumentException("数据不能为空"); - } - - // 验证字段名格式 - for (String field : data.keySet()) { - if (!field.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { - throw new IllegalArgumentException("字段名格式不正确: " + field); - } - } - } - /** - * 验证表是否存在 - */ - public void validateTableExists(String tableName) { - List tables = modelService.getAllModelName(); - if (!tables.contains(tableName)) { - throw new IllegalArgumentException("模型不存在: " + tableName); - } - } - private String getTableName(String modelId) { - return "dynamic_" + modelId.toLowerCase(Locale.ROOT); - } + private ModelDataDao dynamicDao; + @Autowired + private ModelService modelService; + @Autowired + private LoginUserContext loginUserContext; + + private static final Set SYSTEM_FIELDS = Set.of( + "id", "created_at", "updated_at", "deleted_at", "created_by", "updated_by" + ); + + public List query(DynamicQuery dto) { + String tableName = getTableName(dto.getNameEn()); + Map params = new HashMap<>(); + params.put("tableName", tableName); + params.put("fields", dto.getFields()); + params.put("conditions", dto.getParams()); + params.put("pageNum", dto.getCurrentPage()); + params.put("pageSize", dto.getPageSize()); + params.put("orderBy", dto.getOrderBy()); + params.put("orderType", dto.getOrderType()); + + return dynamicDao.select(params); + } + + public Long count(String tableName, Map conditions) { + Map params = new HashMap<>(); + params.put("tableName", tableName); + params.put("fields", Arrays.asList("COUNT(*) as count")); + params.put("conditions", conditions); + + List result = dynamicDao.select(params); + return Long.parseLong(result.get(0).get("count").toString()); + } + + public Map queryWithPage(DynamicQuery dto) { + if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { + throw new IllegalArgumentException("查询操作必须指定模型名称"); + } + if (dto.getCurrentPage() == null || dto.getCurrentPage() <= 0) { + dto.setCurrentPage(1); + } + if (dto.getPageSize() == null || dto.getPageSize() <= 0) { + dto.setPageSize(10); + } + validateTableExists(dto.getNameEn()); + validateConditionKeys(dto.getParams()); + validateQueryFields(dto); + List list = query(dto); + String tableName = getTableName(dto.getNameEn()); + Long total = count(tableName, dto.getParams()); + + Map result = new HashMap<>(); + result.put("list", list); + result.put("total", total); + result.put("pageNum", dto.getCurrentPage()); + result.put("pageSize", dto.getPageSize()); + result.put("pages", (int) Math.ceil((double) total / dto.getPageSize())); + + return result; + } + + @Transactional + public Map insert(DynamicInsert dto) { + if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { + throw new IllegalArgumentException("插入操作必须指定模型名称"); + } + if (dto.getParams() == null || dto.getParams().isEmpty()) { + throw new IllegalArgumentException("插入数据不能为空"); + } + validateTableExists(dto.getNameEn()); + validateTableAndData(dto.getNameEn(), dto.getParams()); + String tableName = getTableName(dto.getNameEn()); + Map params = new HashMap<>(); + params.put("tableName", tableName); + params.put("data", dto.getParams()); + + String userId = loginUserContext.getLoginUserId(); + if (userId == null || userId.trim().isEmpty()) { + List modelList = modelService.getModelByEnName(dto.getNameEn()); + if (modelList.isEmpty()) { + throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); + } else { + userId = modelList.get(0).getCreatedBy(); + } + } + + dto.getParams().put("created_by", userId); + dto.getParams().put("updated_by", userId); + + Map result = new HashMap<>(); + Long insertRow = dynamicDao.insert(params); + BigInteger id = (BigInteger) params.get("id"); + result.put("insert", insertRow); + result.put("id", id.longValue()); + return result; + } + + @Transactional + public Map update(DynamicUpdate dto) { + if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { + throw new IllegalArgumentException("更新操作必须指定模型名称"); + } + if (dto.getParams() == null || dto.getParams().isEmpty()) { + throw new IllegalArgumentException("更新操作必须指定条件"); + } + if (dto.getData() == null || dto.getData().isEmpty()) { + throw new IllegalArgumentException("更新数据不能为空"); + } + validateTableExists(dto.getNameEn()); + validateTableAndData(dto.getNameEn(), dto.getData()); + validateTableAndData(dto.getNameEn(), dto.getParams()); + String tableName = getTableName(dto.getNameEn()); + Map params = new HashMap<>(); + params.put("tableName", tableName); + params.put("data", dto.getData()); + params.put("conditions", dto.getParams()); + Map result = new HashMap<>(); + Integer update = dynamicDao.update(params); + result.put("update", update); + return result; + } + + @Transactional + public Map delete(DynamicDelete dto) { + if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { + throw new IllegalArgumentException("删除操作必须指定模型名称"); + } + if (dto.getId() == null) { + throw new IllegalArgumentException("删除操作必须指定id"); + } + validateTableExists(dto.getNameEn()); + String tableName = getTableName(dto.getNameEn()); + Map params = new HashMap<>(); + Map conditions = new HashMap<>(); + conditions.put("id", dto.getId()); + params.put("tableName", tableName); + params.put("conditions", conditions); + Map result = new HashMap<>(); + Integer delete = dynamicDao.delete(params); + result.put("delete", delete); + return result; + } + + public List> getTableStructure(String tableName) { + validateTableExists(tableName); + return dynamicDao.getTableStructure(tableName); + } + + private Set getAllowedFields(String nameEn) { + List modelList = modelService.getModelByEnName(nameEn); + if (modelList == null || modelList.isEmpty()) { + return Collections.emptySet(); + } + Model model = modelList.get(0); + Set allowed = new HashSet<>(SYSTEM_FIELDS); + if (model.getParameters() != null) { + for (Object param : model.getParameters()) { + String prop = extractProp(param); + if (prop != null) { + allowed.add(prop); + } + } + } + return allowed; + } + + @SuppressWarnings("unchecked") + private String extractProp(Object param) { + if (param instanceof ParametersDto) { + return ((ParametersDto) param).getProp(); + } + if (param instanceof Map) { + Object value = ((Map) param).get("prop"); + return value != null ? value.toString() : null; + } + return null; + } + + private void validateQueryFields(DynamicQuery dto) { + Set allowedFields = getAllowedFields(dto.getNameEn()); + + if (dto.getFields() != null && !dto.getFields().isEmpty()) { + for (String field : dto.getFields()) { + SqlIdentifierValidator.validate(field); + if (!allowedFields.contains(field)) { + throw new IllegalArgumentException("不允许的字段: " + field); + } + } + } + + if (dto.getOrderBy() != null && !dto.getOrderBy().isEmpty()) { + SqlIdentifierValidator.validate(dto.getOrderBy()); + if (!allowedFields.contains(dto.getOrderBy())) { + throw new IllegalArgumentException("不允许的排序字段: " + dto.getOrderBy()); + } + } + + if (dto.getOrderType() != null) { + SqlIdentifierValidator.validateOrderType(dto.getOrderType()); + } + } + + private void validateTableAndData(String tableName, Map data) { + if (tableName == null || tableName.trim().isEmpty()) { + throw new IllegalArgumentException("表名不能为空"); + } + + if (!tableName.matches("^[a-zA-Z_][a-zA-Z0-9_]*$")) { + throw new IllegalArgumentException("表名格式不正确"); + } + + if (data == null || data.isEmpty()) { + throw new IllegalArgumentException("数据不能为空"); + } + + for (String field : data.keySet()) { + SqlIdentifierValidator.validate(field); + } + } + + private void validateConditionKeys(Map conditions) { + if (conditions == null || conditions.isEmpty()) { + return; + } + for (String key : conditions.keySet()) { + SqlIdentifierValidator.validate(key); + } + } + + public void validateTableExists(String tableName) { + List tables = modelService.getAllModelName(); + if (!tables.contains(tableName)) { + throw new IllegalArgumentException("模型不存在: " + tableName); + } + } + private String getTableName(String modelId) { + return "dynamic_" + modelId.toLowerCase(Locale.ROOT); + } } diff --git a/base/src/test/java/com/tinyengine/it/common/utils/SqlIdentifierValidatorTest.java b/base/src/test/java/com/tinyengine/it/common/utils/SqlIdentifierValidatorTest.java new file mode 100644 index 00000000..f0021644 --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/common/utils/SqlIdentifierValidatorTest.java @@ -0,0 +1,112 @@ +package com.tinyengine.it.common.utils; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SqlIdentifierValidatorTest { + + @Test + void validIdentifier() { + assertDoesNotThrow(() -> SqlIdentifierValidator.validate("name")); + assertDoesNotThrow(() -> SqlIdentifierValidator.validate("_name")); + assertDoesNotThrow(() -> SqlIdentifierValidator.validate("Name123")); + assertDoesNotThrow(() -> SqlIdentifierValidator.validate("created_at")); + } + + @Test + void rejectNullIdentifier() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate(null)); + } + + @Test + void rejectEmptyIdentifier() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("")); + } + + @Test + void rejectSqlInjectionInIdentifier() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("@@version")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("1; DROP TABLE users")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("id OR 1=1")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("(SELECT password FROM t_user)")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("name AS leaked")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("name'")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("name\"")); + } + + @Test + void rejectSubqueryInIdentifier() { + assertThrows(IllegalArgumentException.class, + () -> SqlIdentifierValidator.validate("(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables)")); + } + + @Test + void rejectStartingWithDigit() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validate("1name")); + } + + @Test + void validateAllWithValidList() { + List validFields = Arrays.asList("id", "name", "created_at"); + assertDoesNotThrow(() -> SqlIdentifierValidator.validateAll(validFields)); + } + + @Test + void validateAllRejectsInvalidEntry() { + List fields = Arrays.asList("id", "@@version", "name"); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validateAll(fields)); + } + + @Test + void validateAllAcceptsNull() { + assertDoesNotThrow(() -> SqlIdentifierValidator.validateAll(null)); + } + + @Test + void validateOrderTypeAsc() { + assertDoesNotThrow(() -> SqlIdentifierValidator.validateOrderType("ASC")); + assertDoesNotThrow(() -> SqlIdentifierValidator.validateOrderType("asc")); + } + + @Test + void validateOrderTypeDesc() { + assertDoesNotThrow(() -> SqlIdentifierValidator.validateOrderType("DESC")); + assertDoesNotThrow(() -> SqlIdentifierValidator.validateOrderType("desc")); + } + + @Test + void rejectInvalidOrderType() { + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validateOrderType("INVALID")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validateOrderType("; DROP TABLE")); + assertThrows(IllegalArgumentException.class, () -> SqlIdentifierValidator.validateOrderType(null)); + } + + @Test + void rejectSqliPatternsInIdentifier() { + String[] sqliPayloads = { + "@@version", + "@@datadir", + "SLEEP(5)", + "BENCHMARK(10000000,SHA1('test'))", + "LOAD_FILE('/etc/passwd')", + "INTO OUTFILE '/tmp/shell.php'", + "UNION SELECT 1,2,3", + "information_schema.tables", + "1 OR 1=1", + "'; DROP TABLE users--", + "name AND 1=1", + "id; SELECT SLEEP(5)", + "COUNT(*)", + "GROUP_CONCAT(username)", + }; + for (String payload : sqliPayloads) { + assertThrows(IllegalArgumentException.class, + () -> SqlIdentifierValidator.validate(payload), + "Should reject: " + payload); + } + } +} diff --git a/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicModelServiceTest.java b/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicModelServiceTest.java index 11f1d0ea..915d9c33 100644 --- a/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicModelServiceTest.java +++ b/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicModelServiceTest.java @@ -8,6 +8,7 @@ import com.tinyengine.it.dynamic.dto.DynamicUpdate; import com.tinyengine.it.model.dto.ParametersDto; import com.tinyengine.it.model.entity.Model; +import com.tinyengine.it.service.material.ModelService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -37,17 +38,32 @@ class DynamicModelServiceTest { @Mock private LoginUserContext loginUserContext; + @Mock + private ModelService modelService; + @InjectMocks private DynamicModelService dynamicModelService; + private Model testModel; + @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); MockitoAnnotations.openMocks(this); ReflectUtil.setFieldValue(dynamicModelService, "jdbcTemplate", jdbcTemplate); ReflectUtil.setFieldValue(dynamicModelService, "loginUserContext", loginUserContext); ReflectUtil.setFieldValue(dynamicModelService, "namedParameterJdbcTemplate", namedParameterJdbcTemplate); - + ReflectUtil.setFieldValue(dynamicModelService, "modelService", modelService); + + testModel = new Model(); + testModel.setNameEn("test_table"); + testModel.setCreatedBy("1"); + ParametersDto p1 = new ParametersDto(); + p1.setProp("id"); + ParametersDto p2 = new ParametersDto(); + p2.setProp("name"); + testModel.setParameters(Arrays.asList(p1, p2)); + + when(modelService.getModelByEnName("test_table")).thenReturn(Arrays.asList(testModel)); } @@ -180,7 +196,8 @@ void queryWithPage() { dto.setNameEn("test_table"); dto.setFields(Arrays.asList("id", "name")); dto.setParams(Map.of("id", 1)); - dto.setOrderBy("id DESC"); + dto.setOrderBy("id"); + dto.setOrderType("DESC"); dto.setCurrentPage(1); dto.setPageSize(10); diff --git a/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicServiceSqlInjectionTest.java b/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicServiceSqlInjectionTest.java new file mode 100644 index 00000000..3ccab7fb --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/dynamic/service/DynamicServiceSqlInjectionTest.java @@ -0,0 +1,279 @@ +package com.tinyengine.it.dynamic.service; + +import cn.hutool.core.util.ReflectUtil; +import com.alibaba.fastjson.JSONObject; +import com.tinyengine.it.common.context.LoginUserContext; +import com.tinyengine.it.dynamic.dao.ModelDataDao; +import com.tinyengine.it.dynamic.dto.DynamicQuery; +import com.tinyengine.it.dynamic.dto.DynamicUpdate; +import com.tinyengine.it.model.dto.ParametersDto; +import com.tinyengine.it.model.entity.Model; +import com.tinyengine.it.service.material.ModelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +class DynamicServiceSqlInjectionTest { + + @Mock + private ModelDataDao dynamicDao; + + @Mock + private ModelService modelService; + + @Mock + private LoginUserContext loginUserContext; + + @InjectMocks + private DynamicService dynamicService; + + private Model testModel; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + ReflectUtil.setFieldValue(dynamicService, "dynamicDao", dynamicDao); + ReflectUtil.setFieldValue(dynamicService, "modelService", modelService); + ReflectUtil.setFieldValue(dynamicService, "loginUserContext", loginUserContext); + + testModel = new Model(); + testModel.setNameEn("test_model"); + testModel.setCreatedBy("1"); + List params = new ArrayList<>(); + ParametersDto p1 = new ParametersDto(); + p1.setProp("id"); + params.add(p1); + ParametersDto p2 = new ParametersDto(); + p2.setProp("username"); + params.add(p2); + ParametersDto p3 = new ParametersDto(); + p3.setProp("email"); + params.add(p3); + testModel.setParameters(params); + + when(modelService.getAllModelName()).thenReturn(Arrays.asList("test_model")); + when(modelService.getModelByEnName("test_model")).thenReturn(Arrays.asList(testModel)); + } + + private void mockCountResult() { + JSONObject countResult = new JSONObject(); + countResult.put("count", 0L); + // First call returns empty list for query, second call returns count + when(dynamicDao.select(any())) + .thenReturn(Collections.emptyList()) + .thenReturn(Arrays.asList(countResult)); + } + + // --- fields injection prevention --- + + @Test + void queryRejectsSqlExpressionInFields() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setFields(Arrays.asList("id", "@@version AS db_version")); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryRejectsSubqueryInFields() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setFields(Arrays.asList("id", + "(SELECT GROUP_CONCAT(table_name) FROM information_schema.tables WHERE table_schema=DATABASE()) AS all_tables")); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryRejectsPasswordExtractionInFields() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setFields(Arrays.asList("id", "(SELECT password FROM t_user WHERE id=1) AS leaked_pwd")); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryRejectsUnknownFieldNotInModel() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setFields(Arrays.asList("id", "nonexistent_column")); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryAcceptsValidFields() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setFields(Arrays.asList("id", "username")); + dto.setParams(Map.of("id", 1)); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } + + // --- orderBy injection prevention --- + + @Test + void queryRejectsSqlInjectionInOrderBy() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderBy("id; DROP TABLE dynamic_test_model--"); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryRejectsSleepInOrderBy() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderBy("SLEEP(5)"); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryRejectsUnknownOrderByField() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderBy("nonexistent_column"); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryAcceptsValidOrderBy() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderBy("username"); + dto.setOrderType("ASC"); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } + + // --- orderType injection prevention --- + + @Test + void queryRejectsInvalidOrderType() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderType("; DROP TABLE users--"); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryAcceptsAscOrderType() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderType("ASC"); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryAcceptsDescOrderType() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Map.of("id", 1)); + dto.setOrderType("DESC"); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } + + // --- update() condition fields validation --- + + @Test + void updateRejectsInvalidConditionField() { + DynamicUpdate dto = new DynamicUpdate(); + dto.setNameEn("test_model"); + dto.setData(new HashMap<>(Map.of("username", "newvalue"))); + dto.setParams(Map.of("id; DROP TABLE users--", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.update(dto)); + } + + @Test + void updateRejectsInvalidDataField() { + DynamicUpdate dto = new DynamicUpdate(); + dto.setNameEn("test_model"); + dto.setData(Map.of("username; DROP TABLE users--", "newvalue")); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.update(dto)); + } + + // --- validateTableExists --- + + @Test + void queryRejectsUnknownModel() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("nonexistent_model"); + dto.setParams(Map.of("id", 1)); + + assertThrows(IllegalArgumentException.class, () -> dynamicService.queryWithPage(dto)); + } + + // --- queryWithPage accepts null/empty params --- + + @Test + void queryAcceptsNullParams() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(null); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } + + @Test + void queryAcceptsEmptyParams() { + DynamicQuery dto = new DynamicQuery(); + dto.setNameEn("test_model"); + dto.setParams(Collections.emptyMap()); + dto.setCurrentPage(1); + dto.setPageSize(10); + + mockCountResult(); + + assertDoesNotThrow(() -> dynamicService.queryWithPage(dto)); + } +}