• 本文总字数:约 7800 字       预计阅读时间:25 分钟

作为一名 Java 开发者,你是否曾在使用 MyBatis 的 like 语句时如履薄冰?当用户输入带有特殊字符的查询条件,简单的字符串拼接可能瞬间变成黑客入侵的 "后门",导致 SQL 注入攻击,造成数据泄露、篡改甚至服务器被控制等严重后果。

根据 OWASP(开放式 Web 应用安全项目)的统计,SQL 注入连续多年位居 Web 应用安全风险 TOP 10 前列,而 like 语句正是 SQL 注入的重灾区。本文将从原理到实践,全方位解析在 MyBatis 中使用 like 语句时如何有效防止 SQL 注入,通过 5 种实战方案 + 底层原理剖析,让你既知其然,更知其所以然,彻底杜绝这类安全隐患。

一、SQL 注入与 like 语句:为什么这里是重灾区?

在深入解决方案之前,我们必须先理解:为什么 like 语句特别容易引发 SQL 注入问题?

SQL 注入的本质是 "将用户输入的数据当作代码执行"。当我们使用 like 进行模糊查询时,通常需要在查询条件前后添加百分号(%)作为通配符,这种拼接操作恰恰给了攻击者可乘之机。

1.1 一个危险的错误示例

假设我们要实现一个根据用户名模糊查询的功能,很多初学者可能会这样写

<select id="findByUsernameLike" parameterType="String" resultType="User">
    SELECT id, username, email, create_time 
    FROM t_user 
    WHERE username LIKE '%${username}%'
</select>
public interface UserMapper {
    List<User> findByUsernameLike(String username);
}
@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsers(String username) {
        log.info("搜索用户,用户名关键词:{}", username);
        return userMapper.findByUsernameLike(username);
    }
}

这段代码看似简单直接,但隐藏着巨大的安全隐患。当正常用户输入 "张" 时,生成的 SQL 是:

SELECT id, username, email, create_time FROM t_user WHERE username LIKE '%张%'

一切正常。但如果有恶意用户输入的是:张%' OR '1'='1,那么生成的 SQL 就变成了:

SELECT id, username, email, create_time FROM t_user WHERE username LIKE '%张%' OR '1'='1%'

这个 SQL 会查询出表中所有记录,因为'1'='1永远为真!更危险的是,如果输入%' OR 1=1; DROP TABLE t_user;--,可能会直接删除整个表(取决于数据库用户权限)。

1.2 根本原因:$ 与 #的区别

MyBatis 中${}#{}的区别是导致这个问题的核心:

  • ${}是字符串替换,直接将参数拼接到 SQL 中,相当于 JDBC 中的 Statement
  • #{}是参数占位符,会将参数作为一个整体,使用 PreparedStatement 的参数绑定

使用${}时,MyBatis 不会对参数进行任何处理,直接替换到 SQL 中,这就导致了 SQL 注入的风险。而#{}会将参数用引号包裹,并且对特殊字符进行转义,从而防止注入。

因此,在任何情况下,都应优先使用#{}而非${},尤其是处理用户输入时

二、方案一:MyBatis 参数占位符 + CONCAT 函数动态拼接

第一种安全的方案是使用 MyBatis 的参数占位符#{}结合数据库的 CONCAT 函数来动态拼接模糊查询条件。

2.1 实现方式

UserMapper.xml

<select id="findByUsernameLikeWithConcat" parameterType="String" resultType="User">
    SELECT id, username, email, create_time 
    FROM t_user 
    WHERE username LIKE CONCAT('%', #{username}, '%')
</select>

UserMapper.java

public interface UserMapper {
    // 使用CONCAT函数拼接%的模糊查询
    List<User> findByUsernameLikeWithConcat(String username);
}

UserService.java

@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsersWithConcat(String username) {
        // 入参校验
        if (!StringUtils.hasText(username)) {
            log.warn("搜索关键词为空,返回空列表");
            return Collections.emptyList();
        }
        log.info("使用CONCAT方式搜索用户,关键词:{}", username);
        return userMapper.findByUsernameLikeWithConcat(username);
    }
}

2.2 原理分析

这种方式的核心是利用 MySQL 的 CONCAT 函数在 SQL 层面完成字符串拼接,将%符号和用户输入的参数拼接成完整的查询条件。

当用户输入 "张" 时,生成的 SQL 是:

SELECT id, username, email, create_time FROM t_user WHERE username LIKE CONCAT('%', '张', '%')

预处理后实际执行的 SQL 相当于:

SELECT id, username, email, create_time FROM t_user WHERE username LIKE ?

其中?被替换为%张%,但这是作为一个完整的参数值处理的。

即使用户输入恶意内容,比如张%' OR '1'='1,最终的查询条件会变成%张%' OR '1'='1%,MyBatis 会将整个字符串作为一个查询条件,而不是解析为 SQL 语句的一部分,从而有效防止了注入。

2.3 优缺点分析

优点

  • 完全防止 SQL 注入,因为使用了参数绑定
  • 实现简单,只需要修改 SQL 语句
  • 不依赖 Java 代码处理,逻辑集中在 SQL 层面

缺点

  • 依赖数据库的 CONCAT 函数,不同数据库的函数可能不同(如 Oracle 使用||连接符)
  • 如果需要在不同数据库间迁移,可能需要修改 SQL

2.4 跨数据库兼容处理

如果你的应用需要支持多种数据库,可以使用 MyBatis 的数据库厂商标识(databaseIdProvider)来处理:

mybatis-config.xml

<databaseIdProvider type="DB_VENDOR">
    <property name="MySQL" value="mysql"/>
    <property name="Oracle" value="oracle"/>
    <property name="SQL Server" value="sqlserver"/>
</databaseIdProvider>

UserMapper.xml

<select id="findByUsernameLikeWithConcat" parameterType="String" resultType="User">
    <!-- MySQL实现 -->
    <if test="_databaseId == 'mysql'">
        SELECT id, username, email, create_time 
        FROM t_user 
        WHERE username LIKE CONCAT('%', #{username}, '%')
    </if>
    
    <!-- Oracle实现 -->
    <if test="_databaseId == 'oracle'">
        SELECT id, username, email, create_time 
        FROM t_user 
        WHERE username LIKE '%' || #{username} || '%'
    </if>
    
    <!-- SQL Server实现 -->
    <if test="_databaseId == 'sqlserver'">
        SELECT id, username, email, create_time 
        FROM t_user 
        WHERE username LIKE '%' + #{username} + '%'
    </if>
</select>

这种方式可以保证在不同数据库环境下都能安全地使用 like 语句进行模糊查询。

三、方案二:手动拼接 % 并使用参数占位符

第二种方案是在 Java 代码中手动拼接%符号,然后在 MyBatis 映射文件中使用#{}参数占位符。

3.1 实现方式

UserMapper.xm

<select id="findByUsernameLikeWithManualConcat" parameterType="String" resultType="User">
    SELECT id, username, email, create_time 
    FROM t_user 
    WHERE username LIKE #{username}
</select>

UserMapper.java

public interface UserMapper {
    // 手动拼接%的模糊查询
    List<User> findByUsernameLikeWithManualConcat(String username);
}

UserService.java

@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsersWithManualConcat(String username) {
        // 入参校验与处理
        if (!StringUtils.hasText(username)) {
            log.warn("搜索关键词为空,返回空列表");
            return Collections.emptyList();
        }
        
        // 手动拼接%通配符
        String likePattern = "%" + username + "%";
        log.info("使用手动拼接方式搜索用户,处理后的关键词:{}", likePattern);
        return userMapper.findByUsernameLikeWithManualConcat(likePattern);
    }
}

3.2 原理分析

这种方式将%符号的拼接逻辑从 SQL 转移到了 Java 代码中,在调用 Mapper 方法之前就已经将用户输入的关键词包装成%关键词%的形式。

当用户输入 "张" 时,Java 代码会将其处理为 "% 张 %",然后 MyBatis 生成的 SQL 是:

SELECT id, username, email, create_time FROM t_user WHERE username LIKE ?

其中?被安全地替换为 "% 张 %"。

对于恶意输入张%' OR '1'='1,Java 代码会将其处理为%张%' OR '1'='1%,MyBatis 会将整个字符串作为查询条件,而不是解析为 SQL 的一部分,因此不会产生注入风险。

3.3 优缺点分析

优点

  • 完全防止 SQL 注入,使用参数绑定
  • 不依赖数据库特定函数,具有良好的跨数据库兼容性
  • 拼接逻辑在 Java 代码中,便于统一处理和维护

缺点

  • 需要在 Java 代码中处理,增加了代码量
  • 如果有多处类似查询,需要重复编写拼接逻辑
  • 可能会忘记拼接,导致查询结果不符合预期

3.4 优化:使用工具类统一处理

为了避免重复代码,可以创建一个工具类专门处理模糊查询的参数拼接:

LikeQueryUtils.java

/**
 * 模糊查询工具类,处理like语句的参数拼接
 */
public class LikeQueryUtils {
    
    /**
     * 将关键词包装为前后都带%的形式,用于全模糊查询
     * 
     * @param keyword 原始关键词
     * @return 包装后的关键词,如:%keyword%
     */
    public static String wrapWithPercent(String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return "";
        }
        return "%" + keyword + "%";
    }
    
    /**
     * 将关键词包装为前带%的形式,用于后缀模糊查询
     * 
     * @param keyword 原始关键词
     * @return 包装后的关键词,如:%keyword
     */
    public static String wrapWithPrefixPercent(String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return "";
        }
        return "%" + keyword;
    }
    
    /**
     * 将关键词包装为后带%的形式,用于前缀模糊查询
     * 
     * @param keyword 原始关键词
     * @return 包装后的关键词,如:keyword%
     */
    public static String wrapWithSuffixPercent(String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return "";
        }
        return keyword + "%";
    }
}

然后在 Service 中使用:

public List<User> searchUsersWithManualConcat(String username) {
    if (!StringUtils.hasText(username)) {
        log.warn("搜索关键词为空,返回空列表");
        return Collections.emptyList();
    }
    
    // 使用工具类处理
    String likePattern = LikeQueryUtils.wrapWithPercent(username);
    log.info("使用工具类处理的关键词:{}", likePattern);
    return userMapper.findByUsernameLikeWithManualConcat(likePattern);
}

这种方式既保证了代码的复用性,又统一了处理逻辑,减少了出错的可能性。

四、方案三:使用 MyBatis 的 bind 标签

第三种方案是使用 MyBatis 提供的<bind>标签,在 SQL 映射文件中定义一个变量并完成字符串拼接,然后在查询中使用这个变量。

4.1 实现方式

UserMapper.xml

<select id="findByUsernameLikeWithBind" parameterType="String" resultType="User">
    <!-- 使用bind标签定义变量 -->
    <bind name="likePattern" value="'%' + username + '%'" />
    
    SELECT id, username, email, create_time 
    FROM t_user 
    WHERE username LIKE #{likePattern}
</select>

如果参数是一个对象,例如 UserQuery:

UserQuery.java

/**
 * 用户查询条件类
 */
@Data
public class UserQuery {
    // 用户名关键词
    private String username;
    // 邮箱关键词
    private String email;
    // 分页参数
    private Integer pageNum;
    private Integer pageSize;
}

对应的 Mapper.xml:

<select id="findByCondition" parameterType="UserQuery" resultType="User">
    <!-- 对用户名进行模糊查询处理 -->
    <if test="username != null and username != ''">
        <bind name="usernameLike" value="'%' + username + '%'" />
    </if>
    
    <!-- 对邮箱进行模糊查询处理 -->
    <if test="email != null and email != ''">
        <bind name="emailLike" value="'%' + email + '%'" />
    </if>
    
    SELECT id, username, email, create_time 
    FROM t_user 
    WHERE 1=1
    <if test="username != null and username != ''">
        AND username LIKE #{usernameLike}
    </if>
    <if test="email != null and email != ''">
        AND email LIKE #{emailLike}
    </if>
</select>

UserMapper.java

public interface UserMapper {
    // 使用bind标签的模糊查询
    List<User> findByUsernameLikeWithBind(String username);
    
    // 多条件查询,包含bind标签使用
    List<User> findByCondition(UserQuery query);
}

UserService.java

@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsersWithBind(String username) {
        if (!StringUtils.hasText(username)) {
            log.warn("搜索关键词为空,返回空列表");
            return Collections.emptyList();
        }
        
        log.info("使用bind标签方式搜索用户,关键词:{}", username);
        return userMapper.findByUsernameLikeWithBind(username);
    }
    
    public List<User> searchUsersByCondition(UserQuery query) {
        if (Objects.isNull(query)) {
            log.warn("查询条件为空,返回空列表");
            return Collections.emptyList();
        }
        
        log.info("多条件搜索用户,查询参数:{}", query);
        return userMapper.findByCondition(query);
    }
}

4.2 原理分析

MyBatis 的<bind>标签允许我们在 OGNL 表达式中创建一个变量,并将其绑定到当前的上下文。在这个例子中,我们创建了一个名为likePattern的变量,其值是'%' + username + '%',其中username是传入的参数。

然后,在 SQL 语句中使用#{likePattern}来引用这个变量,MyBatis 会将其作为参数绑定,而不是直接拼接,从而防止 SQL 注入。

这种方式的本质是在 MyBatis 框架内部完成了字符串拼接,然后仍然使用参数绑定的方式设置到 SQL 中,兼具了拼接的灵活性和参数绑定的安全性。

4.3 优缺点分析

优点

  • 完全防止 SQL 注入,使用参数绑定
  • 不依赖数据库特定函数,具有良好的跨数据库兼容性
  • 拼接逻辑在 SQL 映射文件中,与 SQL 查询放在一起,便于理解
  • 支持复杂的条件判断和多参数处理

缺点

  • 增加了 XML 文件的复杂度
  • 对于复杂的拼接逻辑,OGNL 表达式可能不够灵活

4.4 高级用法:结合 OGNL 表达式处理特殊情况

<bind>标签支持 OGNL 表达式,可以处理更复杂的场景,例如:

<select id="findByUsernameLikeWithBindAdvanced" parameterType="String" resultType="User">
    <!-- 处理空字符串,避免生成'%%'的情况 -->
    <bind name="likePattern" value="username != null and username != '' ? '%' + username + '%' : null" />
    
    SELECT id, username, email, create_time 
    FROM t_user 
    <where>
        <if test="likePattern != null">
            username LIKE #{likePattern}
        </if>
        <if test="likePattern == null">
            1=1  -- 当没有查询条件时的默认条件
        </if>
    </where>
</select>

这个例子中,我们使用 OGNL 表达式判断参数是否为空,如果为空则将likePattern设为 null,然后在 SQL 中通过<if>标签判断是否添加查询条件,避免了参数为空时生成username LIKE '%%'这样的无意义查询。

五、方案四:使用 MyBatis-Plus 的 QueryWrapper

如果你在项目中使用 MyBatis-Plus(简称 MP),那么可以利用其提供的 QueryWrapper 来优雅地实现安全的模糊查询。

5.1 实现方式

首先确保项目中引入了 MyBatis-Plus 的依赖(以 Maven 为例):

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3.1</version>
</dependency>

User.java(实体类)

@Data
@TableName("t_user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String email;
    @TableField(value = "create_time")
    private LocalDateTime createTime;
}

UserMapper.java(继承 BaseMapper)

public interface UserMapper extends BaseMapper<User> {
    // MP的BaseMapper已经提供了基本的CRUD方法
}

UserService.java

@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsersWithMP(String username) {
        if (!StringUtils.hasText(username)) {
            log.warn("搜索关键词为空,返回空列表");
            return Collections.emptyList();
        }
        
        log.info("使用MyBatis-Plus搜索用户,关键词:{}", username);
        
        // 创建查询条件构造器
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        // 使用like方法,MP会自动处理参数绑定
        queryWrapper.like("username", username);
        
        // 执行查询
        return userMapper.selectList(queryWrapper);
    }
    
    // 多条件复杂查询示例
    public List<User> searchUsersWithMPAdvanced(UserQuery query) {
        if (Objects.isNull(query)) {
            log.warn("查询条件为空,返回空列表");
            return Collections.emptyList();
        }
        
        log.info("使用MyBatis-Plus高级搜索用户,查询参数:{}", query);
        
        // 创建查询条件构造器
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        
        // 用户名模糊查询
        if (StringUtils.hasText(query.getUsername())) {
            queryWrapper.like("username", query.getUsername());
        }
        
        // 邮箱模糊查询
        if (StringUtils.hasText(query.getEmail())) {
            queryWrapper.like("email", query.getEmail());
        }
        
        // 分页处理(需要配合分页插件)
        Page<User> page = new Page<>(query.getPageNum(), query.getPageSize());
        
        // 执行分页查询
        Page<User> resultPage = userMapper.selectPage(page, queryWrapper);
        return resultPage.getRecords();
    }
}

5.2 原理分析

MyBatis-Plus 的 QueryWrapper 在处理 like 方法时,内部会自动使用参数绑定的方式生成 SQL,而不是字符串拼接。

当调用queryWrapper.like("username", username)时,MP 会生成类似以下的 SQL 片段:

username LIKE ?

并将参数值处理为%username%,通过 PreparedStatement 的 setString 方法设置参数,从而避免 SQL 注入。

MP 还提供了不同的模糊查询方法:

  • like(R column, Object val): 等同于LIKE '%val%'
  • likeLeft(R column, Object val): 等同于LIKE '%val'
  • likeRight(R column, Object val): 等同于LIKE 'val%'

这些方法都会安全地处理参数,防止 SQL 注入。

5.3 优缺点分析

优点

  • 完全防止 SQL 注入,底层使用参数绑定
  • 不需要编写 XML 映射文件,通过 Java 代码构建查询条件
  • 提供了丰富的查询方法,满足各种复杂查询需求
  • 支持链式调用,代码简洁易读
  • 自动处理数据库差异,具有良好的跨数据库兼容性

缺点

  • 需要引入 MyBatis-Plus 依赖,适合新项目或可以引入新依赖的项目
  • 对于极其复杂的 SQL,可能仍然需要编写 XML 或使用原生 SQL

5.4 分页查询配置

为了使分页查询生效,需要配置 MyBatis-Plus 的分页插件:

MyBatisPlusConfig.java

@Configuration
@MapperScan("com.example.mapper")
public class MyBatisPlusConfig {
    
    /**
     * 配置分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        // 可以添加其他插件,如乐观锁插件、防全表更新插件等
        return interceptor;
    }
}

这个配置会自动拦截分页查询,并生成分页 SQL,如 MySQL 中的LIMIT语句。

六、方案五:输入校验 —— 从源头阻断恶意输入

无论使用哪种技术方案,输入校验都是不可或缺的一环。通过对用户输入进行严格校验,可以从源头阻断大部分恶意输入,大大提高系统的安全性。

6.1 实现方式

InputValidator.java(输入校验工具类)

/**
 * 输入校验工具类,用于验证用户输入的合法性
 */
public class InputValidator {
    
    // 定义合法的模糊查询关键词正则表达式
    // 只允许字母、数字、汉字、下划线、空格、小数点和部分常用标点
    private static final Pattern VALID_KEYWORD_PATTERN = 
        Pattern.compile("^[a-zA-Z0-9\\u4e00-\\u9fa5_\\s\\.,,。??!!;;::()()]{1,100}$");
    
    /**
     * 验证模糊查询关键词是否合法
     * 
     * @param keyword 待验证的关键词
     * @return 如果合法返回true,否则返回false
     */
    public static boolean isValidKeyword(String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return false;
        }
        return VALID_KEYWORD_PATTERN.matcher(keyword).matches();
    }
    
    /**
     * 清理关键词中的特殊字符
     * 
     * @param keyword 原始关键词
     * @return 清理后的关键词
     */
    public static String sanitizeKeyword(String keyword) {
        if (!StringUtils.hasText(keyword)) {
            return "";
        }
        // 移除所有不合法的字符
        return keyword.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_\\s\\.,,。??!!;;::()()]", "");
    }
}

UserService.java(加入输入校验)

@Slf4j
@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public List<User> searchUsersWithValidation(String username) {
        // 1. 空值校验
        if (!StringUtils.hasText(username)) {
            log.warn("搜索关键词为空,返回空列表");
            return Collections.emptyList();
        }
        
        // 2. 合法性校验
        if (!InputValidator.isValidKeyword(username)) {
            log.warn("搜索关键词包含非法字符,原始输入:{}", username);
            
            // 方案一:直接拒绝查询
            // log.warn("拒绝非法查询,关键词:{}", username);
            // return Collections.emptyList();
            
            // 方案二:清理非法字符后继续查询
            String sanitizedKeyword = InputValidator.sanitizeKeyword(username);
            log.info("已清理非法字符,处理后的关键词:{}", sanitizedKeyword);
            username = sanitizedKeyword;
            
            // 如果清理后为空,返回空列表
            if (!StringUtils.hasText(username)) {
                log.warn("清理后关键词为空,返回空列表");
                return Collections.emptyList();
            }
        }
        
        log.info("经过校验的关键词搜索用户:{}", username);
        
        // 3. 执行查询(以MyBatis-Plus为例)
        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.like("username", username);
        return userMapper.selectList(queryWrapper);
    }
}

6.2 原理分析

输入校验的核心思想是 "不信任任何用户输入",通过以下两种方式提高安全性:

  1. 合法性校验:使用正则表达式等方式检查输入是否符合预期格式,拒绝明显的恶意输入。
  2. 输入清理:移除或转义输入中的特殊字符,即使通过了合法性校验,也对输入进行净化处理。

在模糊查询场景中,我们主要关注可能用于 SQL 注入的特殊字符,如单引号(')、双引号(")、分号(;)、注释符(--、#)等。通过正则表达式,我们只允许输入字母、数字、汉字和部分安全的标点符号。

6.3 优缺点分析

优点

  • 从源头阻断恶意输入,是防御 SQL 注入的第一道防线
  • 可以结合业务场景定制校验规则,灵活性高
  • 减轻后续处理逻辑的安全压力
  • 不仅能防止 SQL 注入,还能防止 XSS 等其他安全问题

缺点

  • 可能误判合法输入,需要谨慎设计校验规则
  • 不能完全替代其他防注入措施,需要与其他方案配合使用
  • 复杂的校验规则可能影响性能

6.4 高级校验:使用 Spring Validation

对于复杂的参数校验,可以使用 Spring 提供的 Validation 框架:

UserQuery.java(使用 Validation 注解)

@Data
public class UserQuery {
    // 用户名关键词,长度1-100,只允许特定字符
    @Pattern(regexp = "^[a-zA-Z0-9\\u4e00-\\u9fa5_\\s\\.,,。??!!;;::()()]{1,100}$", 
             message = "用户名关键词包含非法字符或长度不符")
    private String username;
    
    // 邮箱关键词,符合邮箱格式的模糊查询
    @Pattern(regexp = "^[a-zA-Z0-9_\\-\\.@]{1,100}$", 
             message = "邮箱关键词包含非法字符")
    private String email;
    
    // 页码,最小值为1
    @Min(value = 1, message = "页码不能小于1")
    private Integer pageNum = 1;
    
    // 每页条数,1-100之间
    @Min(value = 1, message = "每页条数不能小于1")
    @Max(value = 100, message = "每页条数不能大于100")
    private Integer pageSize = 10;
}

UserController.java(使用 @Valid 注解触发校验)

@Slf4j
@RestController
@RequestMapping("/users")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    @GetMapping("/search")
    public ResponseEntity<List<User>> searchUsers(@Valid UserQuery query, 
                                                BindingResult bindingResult) {
        // 检查校验结果
        if (bindingResult.hasErrors()) {
            // 获取错误信息
            String errorMsg = bindingResult.getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining(";"));
            log.warn("查询参数校验失败:{}", errorMsg);
            return ResponseEntity.badRequest().body(Collections.emptyList());
        }
        
        List<User> users = userService.searchUsersByCondition(query);
        return ResponseEntity.ok(users);
    }
}

这种方式将校验规则与实体类绑定,通过注解的方式声明校验条件,代码更简洁,且校验逻辑与业务逻辑分离,便于维护。

七、扩展:SQL 注入的深层防御策略

除了上述针对 like 语句的特定方案,我们还应该在系统层面采取一些深层防御策略,构建多层次的安全防线。

7.1 使用最小权限原则配置数据库用户

数据库用户的权限应该遵循 "最小权限原则",即只授予应用程序运行所必需的权限,不授予不必要的权限(如 DROP、ALTER 等)。

例如,对于一个只读查询的应用,可以只授予 SELECT 权限:

-- 创建应用专用用户
CREATE USER 'app_user'@'%' IDENTIFIED BY 'strong_password';

-- 只授予查询权限
GRANT SELECT ON mydb.* TO 'app_user'@'%';

-- 刷新权限
FLUSH PRIVILEGES;

这样即使发生 SQL 注入,攻击者也无法执行删除表、修改数据等破坏性操作,最大限度地降低损失。

7.2 使用数据库连接池的参数过滤

一些数据库连接池或 JDBC 驱动提供了参数过滤功能,可以配置对特定字符进行转义或过滤。

例如,在 HikariCP 连接池中,可以配置连接属性:

spring:
  datasource:
    hikari:
      jdbc-url: jdbc:mysql://localhost:3306/mydb?useUnicode=true&characterEncoding=utf8&allowMultiQueries=false
      username: app_user
      password: strong_password
      driver-class-name: com.mysql.cj.jdbc.Driver

其中allowMultiQueries=false禁止执行多个 SQL 语句,防止攻击者通过分号分隔多个 SQL 的注入方式。

7.3 日志记录与监控

记录详细的 SQL 执行日志和用户操作日志,便于及时发现异常查询和潜在的注入攻击:

@Slf4j
@Aspect
@Component
public class SqlLoggingAspect {
    
    @Around("execution(* com.example.mapper..*(..))")
    public Object logSqlExecution(ProceedingJoinPoint joinPoint) throws Throwable {
        long startTime = System.currentTimeMillis();
        
        try {
            // 执行目标方法
            Object result = joinPoint.proceed();
            long endTime = System.currentTimeMillis();
            
            // 记录正常执行的SQL信息
            log.info("SQL执行成功,方法:{},耗时:{}ms", 
                   joinPoint.getSignature().toShortString(), 
                   endTime - startTime);
            return result;
        } catch (Exception e) {
            // 记录执行异常的SQL信息
            log.error("SQL执行异常,方法:{},参数:{},异常:{}",
                   joinPoint.getSignature().toShortString(),
                   Arrays.toString(joinPoint.getArgs()),
                   e.getMessage(), e);
            throw e;
        }
    }
}

通过监控这些日志,可以及时发现包含异常字符的 SQL 查询,为安全审计和攻击溯源提供依据。

7.4 定期安全审计与渗透测试

定期对系统进行安全审计和渗透测试,模拟黑客攻击的方式来发现潜在的安全漏洞。可以使用专业的安全工具(如 OWASP ZAP、Burp Suite 等)进行自动化测试,同时结合人工代码审查,确保所有 like 语句和其他 SQL 操作都采取了足够的安全措施。

八、总结:如何选择合适的方案?

通过本文的介绍,我们了解了 5 种在 MyBatis 中使用 like 语句时防止 SQL 注入的方案,每种方案都有其适用场景:

  1. 参数占位符 + CONCAT 函数:适用于单数据库环境,SQL 逻辑与 Java 代码分离的场景。
  2. 手动拼接 % 并使用参数占位符:适用于跨数据库环境,希望在 Java 代码中统一处理拼接逻辑的场景。
  3. MyBatis 的 bind 标签:适用于复杂的条件查询,希望在 XML 中集中管理 SQL 逻辑的场景。
  4. MyBatis-Plus 的 QueryWrapper:适用于使用 MyBatis-Plus 框架,希望通过 Java 代码构建查询条件的场景。
  5. 输入校验:这不是一个独立的方案,而是所有方案的基础,必须与其他方案结合使用。

在实际项目中,建议采取 "多层防御" 策略:

  • 首先对所有用户输入进行严格的校验和清理
  • 然后根据项目框架和团队习惯选择合适的 like 查询方案
  • 同时配置数据库最小权限和连接池参数
  • 最后通过日志监控和定期审计确保安全措施的有效性

记住安全是一个持续的过程,没有一劳永逸的解决方案。随着攻击手段的不断进化,我们的防御策略也需要不断更新和完善。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐