实战避坑:MyBatis中${}拼接如何优雅又安全?_mybatis xml 中如何使用 ${} 做白名单校验
“今天安全部门突然发来一封邮件,提示我提交的代码中存在 41 条安全漏洞,顿时脑子嗡的一声——开发不是在写业务代码吗?怎么一不小心就踩了‘雷’?”
这是我今天开发过程中遇到的真实经历。虽然平常对安全规范都有一定的意识,但在实际业务需求推动下,很多“临时处理方案”或“权宜之计”,往往埋下了深深的隐患。这次排查的众多漏洞中,有一个让我尤为警惕——SQL注入。
1. SQL注入
SQL注入(SQL Injection)并不是一个陌生的词汇,它是OWASP列出的十大安全漏洞之一,也是最常见、危害最大的安全问题之一。
你可能会以为只有在拼接SQL字符串、使用Statement而不是PreparedStatement时才会出现注入问题。但实际上,它早已潜伏在你日常的MyBatis XML文件中,尤其是——${}
的使用场景。
比如下面的代码:
<select id=\"selectByField\" resultType=\"User\"> SELECT * FROM user WHERE ${fieldName} = #{value}</select>
这段代码看似简洁优雅,支持动态传入查询字段,满足了业务需求的灵活性。然而,它同时也给了攻击者一个大开门:SQL注入的入口。
2. ${}
与#{}
区别
在 MyBatis 中,${}
与 #{}
是两个经常被混淆的语法。它们的本质区别决定了是否安全:
以登录查询为例:
<select id=\"selectByUsernameAndPassword\" resultType=\"User\"> SELECT * FROM user WHERE username = #{username} AND password = #{password}</select>
这就是推荐做法:使用 #{}
,MyBatis 会自动处理参数预编译,杜绝注入风险。
而如果你使用了 ${}
:
<select id=\"selectByField\" resultType=\"User\"> SELECT * FROM user WHERE ${fieldName} = #{value}</select>
如果传入的 fieldName = \"1=1; DROP TABLE user;\"
,那么灾难就来了。
然而问题也来了:有些场景,不用
${}
就做不了。
3. 使用 ${}
的原因
虽然 ${}
是高危操作,但在某些业务场景中,它却不可或缺。
这就是它的诱惑:用起来太方便了。
以下是一些典型例子:
- 动态拼接表名(如按照日期划分的日志表:
log_202406
,log_202407
) - 动态拼接字段名(如模糊查询时字段可选)
- 动态排序字段(如
ORDER BY ${orderByField}
) - 动态设置更新字段(如在更新时决定字段名)
示例:
<select id=\"getLogFromTable\" resultType=\"LogRecord\"> SELECT * FROM ${tableName} WHERE user_id = #{userId}</select>
这段代码中,${tableName}
必须使用字符串拼接,否则无法替换成表名。
但代价是,你必须手动对
${}
负责 —— 否则就像把数据库交到了攻击者手上。
因此,口诀是:
参数用
#{}
,结构用${}
,结构要防注入,四步护航最靠谱。
4. 五步曲
我们以一个常见场景为例来说明:根据不同表名动态查询数据。
场景需求:
<select id=\"selectFromDynamicTable\" resultType=\"User\"> SELECT * FROM ${tableName} WHERE id = #{id}</select>
这个功能需求很明确,但也十分危险。我们可以使用以下四种方式逐步加强安全性:
1. 白名单过滤
List<String> allowTables = Arrays.asList(\"user\", \"user_backup\", \"user_log\");if (!allowTables.contains(tableName)) { throw new IllegalArgumentException(\"非法表名!\");}
2. 正则校验
if (!tableName.matches(\"^[a-zA-Z0-9_]+$\")) { throw new IllegalArgumentException(\"非法表名格式!\");}
3. 表存在性校验
目标:在执行真正的SQL前,先验证这个表是否在当前数据库中存在。
1. DAO 接口
public interface MetaTableMapper { int countTableExists(@Param(\"tableName\") String tableName);}
2. Mapper XML
<select id=\"countTableExists\" resultType=\"int\"> SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = #{tableName}</select>
3. Service 层示例
@Autowiredprivate MetaTableMapper metaTableMapper;public void validateTableName(String tableName) { if (!tableName.matches(\"^[a-zA-Z0-9_]+$\")) { throw new IllegalArgumentException(\"表名非法!\"); } int count = metaTableMapper.countTableExists(tableName); if (count == 0) { throw new IllegalArgumentException(\"表不存在!\"); }}
4. 使用场景
在执行动态SQL前先校验表名:
public List<User> getUserFromDynamicTable(String tableName, Long userId) { validateTableName(tableName); // 防注入检查 return userMapper.selectFromDynamicTable(tableName, userId);}
5. MyBatis XML 中的动态表查询(结构拼接)
<select id=\"selectFromDynamicTable\" resultType=\"User\"> SELECT * FROM ${tableName} WHERE id = #{userId}</select>
结合表名正则 + 数据库表存在性校验,最大限度降低注入风险。
4. 使用 MyBatis 的 choose/when 标签
<select id=\"selectFromKnownTables\" resultType=\"User\"> SELECT * FROM <choose> <when test=\"tableName == \'user\'\">user</when> <when test=\"tableName == \'user_backup\'\">user_backup</when> <otherwise>invalid_table</otherwise> </choose> WHERE id = #{id}</select>
5. XML 层 OGNL 正则校验防护
以上几步主要聚焦在 Java 层面预防注入,但如果你和我一样,在业务快速开发中已经写了很多 ${tableName}
的动态 SQL,该怎么办?
难道每个调用点都手动去加 Java 校验?或者依赖上游一定记得加白名单?太理想化了。
于是,我们团队最后在 XML 层引入了一个最“硬核”的防线:
MyBatis OGNL 表达式 + 正则校验 + requireNonNull
:
${safeTableName=tableName, @java.util.Objects@requireNonNull( tableName == null || tableName.matches(\"[a-zA-Z][a-zA-Z0-9_]*\") ? \"\" : null, \"tableName is invalid\")}
@java.util.Objects@requireNonNull(...)
${tableName}
之前就完成了表名校验,彻底切断注入风险链条使用方式:
<select id=\"selectFromDynamicTable\" resultType=\"User\"> ${safeTableName=tableName, @java.util.Objects@requireNonNull( tableName == null || tableName.matches(\"[a-zA-Z][a-zA-Z0-9_]*\") ? \"\" : null, \"tableName is invalid\" )} SELECT * FROM ${tableName} WHERE id = #{id}</select>
安全和灵活并不冲突,前提是你愿意多写两行代码。
回到我最开始提到的那 41 个漏洞 —— 最后,我们团队花了一整天逐一修复,而我也逐渐意识到:安全,不只是安全部门的事,更是我们开发者写代码的 “底线工程”。
如果你也在使用 ${}
动态拼接 SQL,请立刻检查你的代码,并落地这“五步曲”。哪怕你只能做最后一步,也比什么都不做强。
安全永远是开发中不可忽视的部分,而SQL注入是最容易被忽略却最危险的漏洞之一。尤其是在 MyBatis 的动态 SQL 场景下,我们常常为了灵活性使用 ${}
,却在不经意间打开了安全后门。