Commit 8618426f1b75e7d7db609ecc4c392726587302fe

Authored by YunaiV
1 parent 86137492

fix(iot): 校验数据库数据目的表名

yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVO.java
... ... @@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.validation.InEnum;
5 5 import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig;
6 6 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
7 7 import io.swagger.v3.oas.annotations.media.Schema;
  8 +import jakarta.validation.Valid;
8 9 import jakarta.validation.constraints.NotEmpty;
9 10 import jakarta.validation.constraints.NotNull;
10 11 import lombok.Data;
... ... @@ -35,6 +36,7 @@ public class IotDataSinkSaveReqVO {
35 36  
36 37 @Schema(description = "数据目的配置")
37 38 @NotNull(message = "数据目的配置不能为空")
  39 + @Valid
38 40 private IotAbstractDataSinkConfig config;
39 41  
40 42 }
41 43 \ No newline at end of file
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/dal/dataobject/rule/config/IotDataSinkDatabaseConfig.java
1 1 package cn.iocoder.yudao.module.iot.dal.dataobject.rule.config;
2 2  
  3 +import jakarta.validation.constraints.NotEmpty;
  4 +import jakarta.validation.constraints.Pattern;
3 5 import lombok.Data;
4 6  
5 7 /**
... ... @@ -15,6 +17,13 @@ import lombok.Data;
15 17 public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig {
16 18  
17 19 /**
  20 + * 目标表名的格式:只允许字母、数字、下划线,且不以数字开头;支持可选的 schema.table 格式
  21 + *
  22 + * 目的:表名会拼接到 INSERT 语句中,保存和执行时都必须校验
  23 + */
  24 + public static final String TABLE_NAME_REGEX = "^[A-Za-z_][A-Za-z0-9_]{0,127}(\\.[A-Za-z_][A-Za-z0-9_]{0,127})?$";
  25 +
  26 + /**
18 27 * JDBC 连接地址
19 28 *
20 29 * 例如:jdbc:mysql://localhost:3306/iot_data
... ... @@ -37,6 +46,8 @@ public class IotDataSinkDatabaseConfig extends IotAbstractDataSinkConfig {
37 46 *
38 47 * 设备消息将以固定结构写入该表
39 48 */
  49 + @NotEmpty(message = "目标表名不能为空")
  50 + @Pattern(regexp = TABLE_NAME_REGEX, message = "目标表名格式不正确,只允许字母、数字、下划线,或 schema.table 格式")
40 51 private String tableName;
41 52  
42 53 }
... ...
yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleAction.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data.action;
2 2  
  3 +import cn.hutool.core.util.ReUtil;
3 4 import cn.hutool.core.util.StrUtil;
4 5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
5 6 import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
... ... @@ -33,6 +34,10 @@ public class IotDatabaseDataRuleAction extends
33 34 @Override
34 35 public void execute(IotDeviceMessage message, IotDataSinkDatabaseConfig config) throws Exception {
35 36 try {
  37 + // 0. 校验表名:执行时再次校验,避免历史保存的非法表名拼接到 SQL 中
  38 + if (!ReUtil.isMatch(IotDataSinkDatabaseConfig.TABLE_NAME_REGEX, config.getTableName())) {
  39 + throw new IllegalArgumentException(StrUtil.format("数据库目的的表名({}) 不合法", config.getTableName()));
  40 + }
36 41 // 1. 获取或创建 JdbcTemplate
37 42 JdbcTemplate jdbcTemplate = getProducer(config);
38 43  
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/controller/admin/rule/vo/data/sink/IotDataSinkSaveReqVOTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.iot.controller.admin.rule.vo.data.sink;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotAbstractDataSinkConfig;
  5 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig;
  6 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkHttpConfig;
  7 +import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
  8 +import jakarta.validation.ConstraintViolation;
  9 +import jakarta.validation.Validation;
  10 +import jakarta.validation.Validator;
  11 +import org.junit.jupiter.api.Test;
  12 +
  13 +import java.util.Arrays;
  14 +import java.util.Set;
  15 +
  16 +import static org.junit.jupiter.api.Assertions.assertEquals;
  17 +import static org.junit.jupiter.api.Assertions.assertTrue;
  18 +
  19 +/**
  20 + * {@link IotDataSinkSaveReqVO} 的参数校验单元测试
  21 + *
  22 + * @author 芋道源码
  23 + */
  24 +public class IotDataSinkSaveReqVOTest {
  25 +
  26 + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
  27 +
  28 + @Test
  29 + public void testValidate_databaseTableNameValid() {
  30 + for (String tableName : Arrays.asList("iot_device_message", "iot_data.device_message")) {
  31 + // 准备参数
  32 + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName));
  33 +
  34 + // 调用,并断言
  35 + assertTrue(validator.validate(reqVO).isEmpty(), tableName);
  36 + }
  37 + }
  38 +
  39 + @Test
  40 + public void testValidate_databaseTableNameInvalid() {
  41 + for (String tableName : Arrays.asList(null, "", "1message", "message; DROP TABLE system_users",
  42 + "message -- ", "message/**/", "`message`", "a.b.c", "message(id)")) {
  43 + // 准备参数
  44 + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.DATABASE.getType(), buildDatabaseConfig(tableName));
  45 +
  46 + // 调用
  47 + Set<ConstraintViolation<IotDataSinkSaveReqVO>> violations = validator.validate(reqVO);
  48 + // 断言:通过 @Valid 级联校验到配置的表名
  49 + assertTrue(!violations.isEmpty(), String.valueOf(tableName));
  50 + violations.forEach(violation -> assertEquals("config.tableName", violation.getPropertyPath().toString()));
  51 + }
  52 + }
  53 +
  54 + @Test
  55 + public void testValidate_otherConfigNotAffected() {
  56 + // 准备参数:其它类型的配置,没有新增的校验约束
  57 + IotDataSinkHttpConfig config = new IotDataSinkHttpConfig();
  58 + config.setUrl("https://www.iocoder.cn");
  59 + IotDataSinkSaveReqVO reqVO = buildReqVO(IotDataSinkTypeEnum.HTTP.getType(), config);
  60 +
  61 + // 调用,并断言
  62 + assertTrue(validator.validate(reqVO).isEmpty());
  63 + }
  64 +
  65 + private static IotDataSinkSaveReqVO buildReqVO(Integer type, IotAbstractDataSinkConfig config) {
  66 + IotDataSinkSaveReqVO reqVO = new IotDataSinkSaveReqVO();
  67 + reqVO.setName("数据目的");
  68 + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
  69 + reqVO.setType(type);
  70 + reqVO.setConfig(config);
  71 + return reqVO;
  72 + }
  73 +
  74 + private static IotDataSinkDatabaseConfig buildDatabaseConfig(String tableName) {
  75 + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig();
  76 + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data");
  77 + config.setTableName(tableName);
  78 + return config;
  79 + }
  80 +
  81 +}
... ...
yudao-module-iot/yudao-module-iot-biz/src/test/java/cn/iocoder/yudao/module/iot/service/rule/data/action/IotDatabaseDataRuleActionTest.java
1 1 package cn.iocoder.yudao.module.iot.service.rule.data.action;
2 2  
  3 +import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
  4 +import cn.iocoder.yudao.module.iot.dal.dataobject.rule.config.IotDataSinkDatabaseConfig;
3 5 import cn.iocoder.yudao.module.iot.enums.rule.IotDataSinkTypeEnum;
4 6 import com.zaxxer.hikari.HikariDataSource;
5 7 import org.junit.jupiter.api.BeforeEach;
... ... @@ -9,9 +11,12 @@ import org.mockito.MockitoAnnotations;
9 11 import org.springframework.jdbc.core.JdbcTemplate;
10 12  
11 13 import javax.sql.DataSource;
  14 +import java.time.LocalDateTime;
  15 +import java.util.Arrays;
12 16  
13 17 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
14 18 import static org.junit.jupiter.api.Assertions.assertEquals;
  19 +import static org.junit.jupiter.api.Assertions.assertThrows;
15 20 import static org.mockito.Mockito.*;
16 21  
17 22 /**
... ... @@ -39,6 +44,41 @@ class IotDatabaseDataRuleActionTest {
39 44 }
40 45  
41 46 @Test
  47 + public void testExecute_tableNameValid() throws Exception {
  48 + // 准备参数:普通表名,以及 schema.table 格式
  49 + for (String tableName : Arrays.asList("iot_device_message", "_message01", "iot_data.device_message")) {
  50 + IotDataSinkDatabaseConfig config = buildConfig(tableName);
  51 + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction);
  52 + doReturn(jdbcTemplate).when(action).getProducer(same(config));
  53 +
  54 + // 调用
  55 + action.execute(buildMessage(), config);
  56 +
  57 + // 断言:表名原样拼接,值使用参数绑定
  58 + verify(jdbcTemplate).update(eq("INSERT INTO " + tableName
  59 + + " (id, device_id, tenant_id, method, report_time, data, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW())"),
  60 + any(), any(), any(), any(), any(), any());
  61 + }
  62 + }
  63 +
  64 + @Test
  65 + public void testExecute_tableNameInvalid() throws Exception {
  66 + // 准备参数:模拟历史已保存的非法表名,执行时也必须拦截
  67 + for (String tableName : Arrays.asList(null, "", "1message", "device message",
  68 + "message; DROP TABLE system_users", "message -- ", "message/**/", "`message`", "\"message\"",
  69 + "message(id)", "a.b.c", ".message", "message.", "表名")) {
  70 + IotDataSinkDatabaseConfig config = buildConfig(tableName);
  71 + IotDatabaseDataRuleAction action = spy(databaseDataRuleAction);
  72 +
  73 + // 调用,并断言异常
  74 + assertThrows(IllegalArgumentException.class, () -> action.execute(buildMessage(), config));
  75 + // 断言:校验在获取连接之前,不会创建连接池、执行 SQL
  76 + verify(action, never()).getProducer(any());
  77 + }
  78 + verifyNoInteractions(jdbcTemplate);
  79 + }
  80 +
  81 + @Test
42 82 public void testCloseProducer_whenHikari() throws Exception {
43 83 // 准备:底层是 HikariDataSource
44 84 HikariDataSource hikari = mock(HikariDataSource.class);
... ... @@ -62,4 +102,18 @@ class IotDatabaseDataRuleActionTest {
62 102 verifyNoInteractions(other);
63 103 }
64 104  
  105 + private static IotDataSinkDatabaseConfig buildConfig(String tableName) {
  106 + IotDataSinkDatabaseConfig config = new IotDataSinkDatabaseConfig();
  107 + config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/iot_data");
  108 + config.setUsername("root");
  109 + config.setPassword("123456");
  110 + config.setTableName(tableName);
  111 + return config;
  112 + }
  113 +
  114 + private static IotDeviceMessage buildMessage() {
  115 + return new IotDeviceMessage().setId("message_01").setDeviceId(1L).setTenantId(1L)
  116 + .setMethod("thing.property.post").setReportTime(LocalDateTime.now());
  117 + }
  118 +
65 119 }
... ...