1. 项目概述为什么MyBatis-Plus的SQL注入话题值得深挖如果你在用MyBatis-Plus或者正准备从原生MyBatis切换过来那你肯定听过关于它“防SQL注入”的各种说法。有人说用了它就能高枕无忧也有人说它只是个包装该注入的还是会注入。作为一个在数据持久层摸爬滚打多年的老码农我今天想和你彻底掰扯清楚这件事。SQL注入不是个新话题但恰恰因为MyBatis-Plus后面简称MP的流行让很多人产生了一种“框架在手安全我有”的错觉这反而可能埋下隐患。MP本质上是对MyBatis的增强工具包它提供了很多开箱即用的CRUD方法、条件构造器、代码生成器极大提升了开发效率。但效率提升的同时我们必须清醒地认识到它并没有改变MyBatis底层使用预编译语句PreparedStatement来防止SQL注入的核心机制。MP的“防注入”能力完全取决于你如何使用它提供的各种API。用对了它是坚固的盾牌用错了或者理解有偏差它可能给你一种虚假的安全感甚至亲手为你打开注入的大门。这篇文章我们就抛开那些笼统的概念深入到MP的具体使用场景中。我会带你拆解MP中与SQL构建相关的几个核心模块条件构造器QueryWrapper/UpdateWrapper、自定义SQL、以及Select注解等逐一分析在什么情况下是安全的什么情况下是危险的以及背后的原理到底是什么。更重要的是我会分享一些在真实项目审计和压测中遇到的、文档里不会写的“坑”和最佳实践。无论你是刚接触MP的新手还是已经用它写过不少业务的老手相信都能从中获得一些新的、落地的认知。2. MyBatis-Plus安全机制的核心理解预编译的边界在深入MP之前我们必须回到原点理解MyBatis是如何防御SQL注入的。这关系到MP所有“安全”特性的根基。2.1 MyBatis的预编译原理安全基石MyBatis防止SQL注入的核心在于它强制要求所有传入SQL的参数都必须通过#{}占位符来传递。当你在Mapper XML中写下SELECT * FROM user WHERE id #{userId}时MyBatis在底层会做这样几件事SQL解析与预编译MyBatis会将这条SQL语句发送给数据库驱动驱动会将其转换为一个预编译语句PreparedStatement。对于数据库来说#{userId}被看作一个参数占位符通常是?而不是SQL语句的一部分。此时SQL的语法结构如SELECT, FROM, WHERE, 已经被数据库解析和固定。参数传递当执行这条语句时MyBatis才会将具体的userId参数值传递给PreparedStatement。参数化设置数据库驱动会调用PreparedStatement.setXxx()方法如setInt,setString将参数值安全地“填充”到占位符的位置。关键就在这里无论userId的值是什么比如是1还是恶意的1 OR 11它都会被数据库视为一个纯粹的“数据值”而不是可执行的“SQL代码”。数据库不会去解析或执行这个值中的SQL关键字。这个过程就是“参数化查询”。它从根本上将代码SQL结构和数据参数值分离使得用户输入无法改变原有的SQL语义从而杜绝了注入。注意与之相对的是${}符号。它会直接将参数值进行字符串替换然后拼接成完整的SQL语句发送给数据库。如果这个参数值来自用户输入且未经验证那么经典的‘ OR ‘1’‘1注入就会立刻生效。在MyBatis中${}通常只用于动态传入表名、列名等非用户数据的场景并且需要非常谨慎。2.2 MyBatis-Plus的定位增强而非颠覆理解了MyBatis的安全基础我们再来看MP。MP并没有重新发明轮子去搞一套新的安全机制。它的所有CRUD接口和条件构造器最终生成的SQL语句其参数部分依然是通过#{}占位符传递给MyBatis的。也就是说只要你通过MP提供的标准方式如lambdaQuery().eq(...)来构建查询条件你得到的依然是参数化查询是安全的。MP的贡献在于它通过Java链式调用或Lambda表达式提供了一种类型安全、更加直观的方式来动态构建复杂的查询条件避免了手动在XML里拼接if标签的繁琐和易错。但请记住这个“构建”过程是在Java代码层生成带有#{}占位符的SQL片段和对应的参数映射最后交给MyBatis去执行安全的预编译。安全的责任最终仍然由MyBatis的预编译机制承担。所以MP解决SQL注入的第一要义是它通过设计良好的API引导你走向正确的、使用#{}的参数化查询之路并尽可能让你远离手动拼接字符串的诱惑。然而一旦你开始脱离这些标准API或者对某些“便捷”功能使用不当风险就出现了。3. 条件构造器Wrapper的安全使用与风险陷阱条件构造器QueryWrapper,LambdaQueryWrapper,UpdateWrapper等是MP最亮眼的功能之一也是日常使用最频繁的部分。它的安全性是“有条件”的。3.1 安全的使用方式链式调用与Lambda表达式这是MP官方推荐也是最安全的方式。所有通过.eq(),.ne(),.like(),.gt(),.lt(),.in()等方法添加的条件其参数值都会自动被处理为预编译参数。// 示例LambdaQueryWrapper类型安全推荐 LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getName, userInputName) // userInputName 来自前端输入 .gt(User::getAge, minAge) .likeRight(User::getEmail, “domain.com”); // 右模糊匹配 ListUser list userMapper.selectList(lqw);// 示例QueryWrapper QueryWrapperUser qw new QueryWrapper(); qw.eq(“name”, userInputName) .gt(“age”, minAge) .likeRight(“email”, “domain.com”); ListUser list userMapper.selectList(qw);在上述代码中无论userInputName被用户输入为什么内容比如admin’ --MP在构建SQL时生成的会是WHERE name ?并将admin’ --这个字符串整体作为一个参数值设置进去。数据库会去查找name字段等于admin’ --的记录而不会把--解释为SQL注释。这就是安全的。实操心得优先使用LambdaQueryWrapper和LambdaUpdateWrapper。它们通过方法引用来指定字段在编译期就能检查字段名是否正确避免了因手误写错字符串列名导致的运行时错误同时也让代码重构如字段重命名更加方便。这是安全性和开发体验的双重提升。3.2 高风险陷阱apply与last方法的滥用Wrapper接口提供了apply(String applySql, Object… params)和last(String lastSql)方法用于添加自定义的SQL片段。这里是SQL注入风险的重灾区。apply方法本意是用于添加一些特殊的、MP未内置的查询条件如数据库函数。它的正确用法是将变量部分依然用{0},{1}作为占位符并通过后面的params参数传入。// 【危险】错误用法直接拼接用户输入 String userInput “admin’ OR ‘1’‘1”; qw.apply(“name ‘“ userInput “‘“); // 直接字符串拼接产生注入漏洞 // 生成的SQL: WHERE name ‘admin’ OR ‘1’‘1’ 永真条件数据全被查出 // 【安全】正确用法使用占位符 qw.apply(“date_format(create_time, ‘%Y-%m-%d’) {0}”, “2023-10-27”); // 生成的SQL: WHERE date_format(create_time, ‘%Y-%m-%d’) ? // 参数值: “2023-10-27”last方法用于在SQL语句末尾追加内容常用于添加LIMIT,FOR UPDATE等。这个方法极其危险因为它直接进行字符串拼接没有任何参数化处理。// 【极度危险】绝对禁止将用户输入用于 last String userLimit “10; DROP TABLE user; --”; qw.last(“LIMIT “ userLimit); // 灾难性后果 // 生成的SQL: SELECT ... FROM user LIMIT 10; DROP TABLE user; -- // 如果数据库支持多语句执行user表就被删除了。 // 【相对安全】的用法仅追加固定的、开发者可控的SQL片段 qw.last(“LIMIT 10”); qw.last(“FOR UPDATE”);重要警告last方法应被视为一个“逃生舱口”仅在极少数需要追加固定SQL片段时使用。任何来自用户输入、外部配置或未经严格校验的数据都绝对禁止传入apply除非用占位符和last方法。在代码审查中对这两个方法的调用必须重点关照。3.3 动态排序orderBy的安全考量orderBy方法用于指定排序字段和顺序。排序字段名通常不是用户数据但可能由前端动态传入如点击表头排序。// 前端传入 sortField “name”, sortOrder “ASC” String sortField request.getParameter(“sortField”); String sortOrder request.getParameter(“sortOrder”); // 【有风险】直接使用字符串拼接 qw.orderBy(true, “ASC”.equals(sortOrder), sortField); // 如果 sortField 被恶意传入 “name; DROP TABLE user”虽然不一定能注入成功因为ORDER BY子句语法限制但可能引发数据库错误。 // 【安全实践】白名单校验 ListString allowedSortFields Arrays.asList(“name”, “age”, “create_time”); if (allowedSortFields.contains(sortField)) { qw.orderBy(true, “ASC”.equals(sortOrder), sortField); } else { qw.orderBy(true, true, “id”); // 默认排序 }排查技巧对于动态表头、动态查询字段这类场景建立“字段白名单”机制是必须的。在接收到前端传入的字段名后先与预定义的可排序/可查询字段列表进行比对只有在白名单内的字段才被允许用于构建SQL。这能有效防止通过字段名进行试探性攻击。4. 自定义SQL与XML映射文件中的安全红线当MP内置的方法无法满足复杂查询时我们仍需回归到MyBatis的自定义SQL。这里的安全规则就是MyBatis的铁律。4.1 XML Mapper中的#{}与${}这是老生常谈但至关重要。在Mapper XML文件中#{param}安全。使用预编译参数。${param}危险。直接文本替换。!-- 【安全】 -- select id“selectByCondition” resultType“User” SELECT * FROM user WHERE name #{name} AND age #{minAge} if test“email ! null” AND email LIKE CONCAT(‘%’, #{email}, ‘%’) /if /select !-- 【危险】用户输入直接用于表名或列名且未过滤 -- select id“dynamicTableQuery” resultType“map” SELECT * FROM ${tableName} WHERE id #{id} /select !-- 如果 tableName 来自用户输入为 user; DELETE FROM user --后果不堪设想。 -- !-- 【可接受但需谨慎】${} 用于静态或严格控制的场景 -- select id“selectFromFixedPartition” resultType“Log” SELECT * FROM log_${yearMonth} WHERE level #{level} /select !-- 假设 yearMonth 是后台生成的‘202310’相对可控但仍需确保其格式正确。 --实操心得对于${}的使用我个人的原则是“非必要不使用”。仅在动态表名如分表、动态列名极少数报表场景且参数值完全由后台逻辑生成、绝对不受用户输入影响时才考虑使用。并且即使后台生成也要对值进行严格的格式校验如分表后缀是否匹配正则^d{6}$。4.2 注解式SQL (Select,Update等) 的注意事项MP也支持在Mapper接口的方法上直接使用Select、Update等注解编写SQL。其安全规则与XML完全一致。// 【安全】 Select(“SELECT * FROM user WHERE name #{name} AND status #{status}”) ListUser selectActiveUserByName(Param(“name”) String name, Param(“status”) Integer status); // 【危险】 Select(“SELECT * FROM user WHERE name ‘${name}’“) // 直接拼接注入漏洞 ListUser selectUserByNameUnsafe(Param(“name”) String name);常见问题在注解中编写较长的复杂SQL时可读性和维护性会变差。对于多行动态SQL更推荐使用XML方式可以利用if,choose,foreach等标签更优雅地处理。注解方式更适合短小、固定的SQL语句。5. 插件与全局配置对安全性的影响MP提供了一些插件和全局配置它们本身不直接导致注入但错误配置可能降低安全性或掩盖问题。5.1 性能分析插件与 SQL 打印PerformanceInterceptor或P6Spy这类插件可以打印执行SQL便于调试。务必注意生产环境的配置。mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL在日志中你会看到两种SQLPreparing:SELECT * FROM user WHERE name ?Parameters:admin’ -- (String)这是安全的它展示的是预编译语句和参数。危险在于如果你不小心将包含真实参数值的完整SQL拼接后的记录到生产日志或监控系统这些日志可能被泄露其中包含的用户数据如手机号、邮箱会造成二次安全风险。确保生产环境仅记录WARN或ERROR级别日志或对SQL日志进行脱敏处理。5.2 全局配置sql-injector与自定义方法MP允许你编写自己的SqlInjector来添加全局自定义方法。在编写这类扩展时你必须确保在生成SQL片段时对任何来自方法参数的数据都使用#{}占位符进行处理。如果你在自定义方法中采用了字符串拼接的方式将参数直接嵌入SQL模板那么这个自定义方法就会成为整个应用的一个注入点。审查自定义注入器代码的安全性应与审查业务代码同等重要。6. 全流程防御编码之外的保障措施框架和规范能解决大部分问题但完整的防御还需要体系化的措施。6.1 输入验证与过滤这是老生常谈但永不过时的第一道防线。在参数进入Mapper层之前在Controller或Service层进行校验。类型校验确保年龄是数字邮箱符合格式。长度限制防止过长的字符串导致异常或潜在的缓冲区问题。业务规则校验状态值是否在枚举范围内ID是否为正数等。敏感词过滤对于搜索框等场景可以过滤掉明显的SQL关键字如DROP,UNION,SELECT等但这只是一种辅助手段不能替代参数化查询。注意不要过度过滤以免影响正常业务比如用户想搜索包含“select”这个词的文章。推荐使用Jakarta Bean Validation即Valid注解或Spring Validation来声明式地进行校验。6.2 最小权限原则连接数据库的应用程序账号不应该拥有DROP,DELETE TABLE,GRANT等高风险权限。通常只赋予SELECT,INSERT,UPDATE,DELETE等必要的操作权限并且最好限制在特定的业务数据库或表上。这样即使发生注入攻击者能造成的破坏也有限。6.3 定期依赖更新与安全扫描保持MyBatis、MyBatis-Plus、数据库驱动等依赖的版本为最新稳定版以获取已知漏洞的修复。同时将SQL注入检查纳入代码审查清单并可以使用SonarQube、Fortify等静态代码分析工具进行自动化扫描它们能有效识别出代码中${}的不当使用和字符串拼接SQL的模式。6.4 模糊查询的正确姿势模糊查询LIKE是一个需要特别留神的点。很多人会这样写qw.like(“name”, “%” userInput “%”);这是安全的因为MP会将整个“%” userInput “%”作为一个字符串参数传给#{}。但是如果你需要在XML中手动编写LIKE语句正确做法是if test“keyword ! null and keyword ! ‘‘“ AND (name LIKE CONCAT(‘%’, #{keyword}, ‘%’) OR email LIKE CONCAT(‘%’, #{keyword}, ‘%’)) /if绝对不要写成AND name LIKE ‘%${keyword}%’。7. 总结与核心心法回到最初的问题MyBatis-Plus如何解决SQL注入我的答案是它通过提供一套类型安全、便捷的API最大限度地引导开发者走向正确的参数化查询#{}道路但“解决”的前提是开发者必须遵循这套安全范式。你可以将MP的安全使用总结为以下几条核心心法默认安全坚信并坚持使用MP条件构造器的链式方法eq,gt,like等和Lambda表达式。这是最安全、最推荐的方式。红线意识将apply未使用占位符和last方法视为高危操作。任何用户输入都不得直接传入这两个方法。使用apply时必须配合{0}占位符。严守传统在自定义SQLXML或注解中坚决执行MyBatis的规则数据用#{}SQL关键字/结构用${}并极度谨慎。动态字段名、表名必须经过白名单校验。纵深防御不要依赖单一防线。结合输入校验、最小权限、日志管理、安全扫描构建从应用到数据库的多层防御体系。保持清醒不要因为使用了MP就放松对SQL注入的警惕。框架是工具安全的责任最终在于使用工具的人。定期回顾和审计代码中的SQL相关操作尤其是涉及动态拼接的地方。最后我个人最深刻的体会是安全往往不是被复杂的技术攻破的而是被“图省事”的心态和“我以为”的错觉所瓦解。在每一次调用apply或写下${}时多问自己一句“这个值用户能控制吗我百分之百确定吗” 这份审慎比任何高级框架都来得重要。
MyBatis-Plus防SQL注入:从预编译原理到安全实践
1. 项目概述为什么MyBatis-Plus的SQL注入话题值得深挖如果你在用MyBatis-Plus或者正准备从原生MyBatis切换过来那你肯定听过关于它“防SQL注入”的各种说法。有人说用了它就能高枕无忧也有人说它只是个包装该注入的还是会注入。作为一个在数据持久层摸爬滚打多年的老码农我今天想和你彻底掰扯清楚这件事。SQL注入不是个新话题但恰恰因为MyBatis-Plus后面简称MP的流行让很多人产生了一种“框架在手安全我有”的错觉这反而可能埋下隐患。MP本质上是对MyBatis的增强工具包它提供了很多开箱即用的CRUD方法、条件构造器、代码生成器极大提升了开发效率。但效率提升的同时我们必须清醒地认识到它并没有改变MyBatis底层使用预编译语句PreparedStatement来防止SQL注入的核心机制。MP的“防注入”能力完全取决于你如何使用它提供的各种API。用对了它是坚固的盾牌用错了或者理解有偏差它可能给你一种虚假的安全感甚至亲手为你打开注入的大门。这篇文章我们就抛开那些笼统的概念深入到MP的具体使用场景中。我会带你拆解MP中与SQL构建相关的几个核心模块条件构造器QueryWrapper/UpdateWrapper、自定义SQL、以及Select注解等逐一分析在什么情况下是安全的什么情况下是危险的以及背后的原理到底是什么。更重要的是我会分享一些在真实项目审计和压测中遇到的、文档里不会写的“坑”和最佳实践。无论你是刚接触MP的新手还是已经用它写过不少业务的老手相信都能从中获得一些新的、落地的认知。2. MyBatis-Plus安全机制的核心理解预编译的边界在深入MP之前我们必须回到原点理解MyBatis是如何防御SQL注入的。这关系到MP所有“安全”特性的根基。2.1 MyBatis的预编译原理安全基石MyBatis防止SQL注入的核心在于它强制要求所有传入SQL的参数都必须通过#{}占位符来传递。当你在Mapper XML中写下SELECT * FROM user WHERE id #{userId}时MyBatis在底层会做这样几件事SQL解析与预编译MyBatis会将这条SQL语句发送给数据库驱动驱动会将其转换为一个预编译语句PreparedStatement。对于数据库来说#{userId}被看作一个参数占位符通常是?而不是SQL语句的一部分。此时SQL的语法结构如SELECT, FROM, WHERE, 已经被数据库解析和固定。参数传递当执行这条语句时MyBatis才会将具体的userId参数值传递给PreparedStatement。参数化设置数据库驱动会调用PreparedStatement.setXxx()方法如setInt,setString将参数值安全地“填充”到占位符的位置。关键就在这里无论userId的值是什么比如是1还是恶意的1 OR 11它都会被数据库视为一个纯粹的“数据值”而不是可执行的“SQL代码”。数据库不会去解析或执行这个值中的SQL关键字。这个过程就是“参数化查询”。它从根本上将代码SQL结构和数据参数值分离使得用户输入无法改变原有的SQL语义从而杜绝了注入。注意与之相对的是${}符号。它会直接将参数值进行字符串替换然后拼接成完整的SQL语句发送给数据库。如果这个参数值来自用户输入且未经验证那么经典的‘ OR ‘1’‘1注入就会立刻生效。在MyBatis中${}通常只用于动态传入表名、列名等非用户数据的场景并且需要非常谨慎。2.2 MyBatis-Plus的定位增强而非颠覆理解了MyBatis的安全基础我们再来看MP。MP并没有重新发明轮子去搞一套新的安全机制。它的所有CRUD接口和条件构造器最终生成的SQL语句其参数部分依然是通过#{}占位符传递给MyBatis的。也就是说只要你通过MP提供的标准方式如lambdaQuery().eq(...)来构建查询条件你得到的依然是参数化查询是安全的。MP的贡献在于它通过Java链式调用或Lambda表达式提供了一种类型安全、更加直观的方式来动态构建复杂的查询条件避免了手动在XML里拼接if标签的繁琐和易错。但请记住这个“构建”过程是在Java代码层生成带有#{}占位符的SQL片段和对应的参数映射最后交给MyBatis去执行安全的预编译。安全的责任最终仍然由MyBatis的预编译机制承担。所以MP解决SQL注入的第一要义是它通过设计良好的API引导你走向正确的、使用#{}的参数化查询之路并尽可能让你远离手动拼接字符串的诱惑。然而一旦你开始脱离这些标准API或者对某些“便捷”功能使用不当风险就出现了。3. 条件构造器Wrapper的安全使用与风险陷阱条件构造器QueryWrapper,LambdaQueryWrapper,UpdateWrapper等是MP最亮眼的功能之一也是日常使用最频繁的部分。它的安全性是“有条件”的。3.1 安全的使用方式链式调用与Lambda表达式这是MP官方推荐也是最安全的方式。所有通过.eq(),.ne(),.like(),.gt(),.lt(),.in()等方法添加的条件其参数值都会自动被处理为预编译参数。// 示例LambdaQueryWrapper类型安全推荐 LambdaQueryWrapperUser lqw new LambdaQueryWrapper(); lqw.eq(User::getName, userInputName) // userInputName 来自前端输入 .gt(User::getAge, minAge) .likeRight(User::getEmail, “domain.com”); // 右模糊匹配 ListUser list userMapper.selectList(lqw);// 示例QueryWrapper QueryWrapperUser qw new QueryWrapper(); qw.eq(“name”, userInputName) .gt(“age”, minAge) .likeRight(“email”, “domain.com”); ListUser list userMapper.selectList(qw);在上述代码中无论userInputName被用户输入为什么内容比如admin’ --MP在构建SQL时生成的会是WHERE name ?并将admin’ --这个字符串整体作为一个参数值设置进去。数据库会去查找name字段等于admin’ --的记录而不会把--解释为SQL注释。这就是安全的。实操心得优先使用LambdaQueryWrapper和LambdaUpdateWrapper。它们通过方法引用来指定字段在编译期就能检查字段名是否正确避免了因手误写错字符串列名导致的运行时错误同时也让代码重构如字段重命名更加方便。这是安全性和开发体验的双重提升。3.2 高风险陷阱apply与last方法的滥用Wrapper接口提供了apply(String applySql, Object… params)和last(String lastSql)方法用于添加自定义的SQL片段。这里是SQL注入风险的重灾区。apply方法本意是用于添加一些特殊的、MP未内置的查询条件如数据库函数。它的正确用法是将变量部分依然用{0},{1}作为占位符并通过后面的params参数传入。// 【危险】错误用法直接拼接用户输入 String userInput “admin’ OR ‘1’‘1”; qw.apply(“name ‘“ userInput “‘“); // 直接字符串拼接产生注入漏洞 // 生成的SQL: WHERE name ‘admin’ OR ‘1’‘1’ 永真条件数据全被查出 // 【安全】正确用法使用占位符 qw.apply(“date_format(create_time, ‘%Y-%m-%d’) {0}”, “2023-10-27”); // 生成的SQL: WHERE date_format(create_time, ‘%Y-%m-%d’) ? // 参数值: “2023-10-27”last方法用于在SQL语句末尾追加内容常用于添加LIMIT,FOR UPDATE等。这个方法极其危险因为它直接进行字符串拼接没有任何参数化处理。// 【极度危险】绝对禁止将用户输入用于 last String userLimit “10; DROP TABLE user; --”; qw.last(“LIMIT “ userLimit); // 灾难性后果 // 生成的SQL: SELECT ... FROM user LIMIT 10; DROP TABLE user; -- // 如果数据库支持多语句执行user表就被删除了。 // 【相对安全】的用法仅追加固定的、开发者可控的SQL片段 qw.last(“LIMIT 10”); qw.last(“FOR UPDATE”);重要警告last方法应被视为一个“逃生舱口”仅在极少数需要追加固定SQL片段时使用。任何来自用户输入、外部配置或未经严格校验的数据都绝对禁止传入apply除非用占位符和last方法。在代码审查中对这两个方法的调用必须重点关照。3.3 动态排序orderBy的安全考量orderBy方法用于指定排序字段和顺序。排序字段名通常不是用户数据但可能由前端动态传入如点击表头排序。// 前端传入 sortField “name”, sortOrder “ASC” String sortField request.getParameter(“sortField”); String sortOrder request.getParameter(“sortOrder”); // 【有风险】直接使用字符串拼接 qw.orderBy(true, “ASC”.equals(sortOrder), sortField); // 如果 sortField 被恶意传入 “name; DROP TABLE user”虽然不一定能注入成功因为ORDER BY子句语法限制但可能引发数据库错误。 // 【安全实践】白名单校验 ListString allowedSortFields Arrays.asList(“name”, “age”, “create_time”); if (allowedSortFields.contains(sortField)) { qw.orderBy(true, “ASC”.equals(sortOrder), sortField); } else { qw.orderBy(true, true, “id”); // 默认排序 }排查技巧对于动态表头、动态查询字段这类场景建立“字段白名单”机制是必须的。在接收到前端传入的字段名后先与预定义的可排序/可查询字段列表进行比对只有在白名单内的字段才被允许用于构建SQL。这能有效防止通过字段名进行试探性攻击。4. 自定义SQL与XML映射文件中的安全红线当MP内置的方法无法满足复杂查询时我们仍需回归到MyBatis的自定义SQL。这里的安全规则就是MyBatis的铁律。4.1 XML Mapper中的#{}与${}这是老生常谈但至关重要。在Mapper XML文件中#{param}安全。使用预编译参数。${param}危险。直接文本替换。!-- 【安全】 -- select id“selectByCondition” resultType“User” SELECT * FROM user WHERE name #{name} AND age #{minAge} if test“email ! null” AND email LIKE CONCAT(‘%’, #{email}, ‘%’) /if /select !-- 【危险】用户输入直接用于表名或列名且未过滤 -- select id“dynamicTableQuery” resultType“map” SELECT * FROM ${tableName} WHERE id #{id} /select !-- 如果 tableName 来自用户输入为 user; DELETE FROM user --后果不堪设想。 -- !-- 【可接受但需谨慎】${} 用于静态或严格控制的场景 -- select id“selectFromFixedPartition” resultType“Log” SELECT * FROM log_${yearMonth} WHERE level #{level} /select !-- 假设 yearMonth 是后台生成的‘202310’相对可控但仍需确保其格式正确。 --实操心得对于${}的使用我个人的原则是“非必要不使用”。仅在动态表名如分表、动态列名极少数报表场景且参数值完全由后台逻辑生成、绝对不受用户输入影响时才考虑使用。并且即使后台生成也要对值进行严格的格式校验如分表后缀是否匹配正则^d{6}$。4.2 注解式SQL (Select,Update等) 的注意事项MP也支持在Mapper接口的方法上直接使用Select、Update等注解编写SQL。其安全规则与XML完全一致。// 【安全】 Select(“SELECT * FROM user WHERE name #{name} AND status #{status}”) ListUser selectActiveUserByName(Param(“name”) String name, Param(“status”) Integer status); // 【危险】 Select(“SELECT * FROM user WHERE name ‘${name}’“) // 直接拼接注入漏洞 ListUser selectUserByNameUnsafe(Param(“name”) String name);常见问题在注解中编写较长的复杂SQL时可读性和维护性会变差。对于多行动态SQL更推荐使用XML方式可以利用if,choose,foreach等标签更优雅地处理。注解方式更适合短小、固定的SQL语句。5. 插件与全局配置对安全性的影响MP提供了一些插件和全局配置它们本身不直接导致注入但错误配置可能降低安全性或掩盖问题。5.1 性能分析插件与 SQL 打印PerformanceInterceptor或P6Spy这类插件可以打印执行SQL便于调试。务必注意生产环境的配置。mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 控制台打印SQL在日志中你会看到两种SQLPreparing:SELECT * FROM user WHERE name ?Parameters:admin’ -- (String)这是安全的它展示的是预编译语句和参数。危险在于如果你不小心将包含真实参数值的完整SQL拼接后的记录到生产日志或监控系统这些日志可能被泄露其中包含的用户数据如手机号、邮箱会造成二次安全风险。确保生产环境仅记录WARN或ERROR级别日志或对SQL日志进行脱敏处理。5.2 全局配置sql-injector与自定义方法MP允许你编写自己的SqlInjector来添加全局自定义方法。在编写这类扩展时你必须确保在生成SQL片段时对任何来自方法参数的数据都使用#{}占位符进行处理。如果你在自定义方法中采用了字符串拼接的方式将参数直接嵌入SQL模板那么这个自定义方法就会成为整个应用的一个注入点。审查自定义注入器代码的安全性应与审查业务代码同等重要。6. 全流程防御编码之外的保障措施框架和规范能解决大部分问题但完整的防御还需要体系化的措施。6.1 输入验证与过滤这是老生常谈但永不过时的第一道防线。在参数进入Mapper层之前在Controller或Service层进行校验。类型校验确保年龄是数字邮箱符合格式。长度限制防止过长的字符串导致异常或潜在的缓冲区问题。业务规则校验状态值是否在枚举范围内ID是否为正数等。敏感词过滤对于搜索框等场景可以过滤掉明显的SQL关键字如DROP,UNION,SELECT等但这只是一种辅助手段不能替代参数化查询。注意不要过度过滤以免影响正常业务比如用户想搜索包含“select”这个词的文章。推荐使用Jakarta Bean Validation即Valid注解或Spring Validation来声明式地进行校验。6.2 最小权限原则连接数据库的应用程序账号不应该拥有DROP,DELETE TABLE,GRANT等高风险权限。通常只赋予SELECT,INSERT,UPDATE,DELETE等必要的操作权限并且最好限制在特定的业务数据库或表上。这样即使发生注入攻击者能造成的破坏也有限。6.3 定期依赖更新与安全扫描保持MyBatis、MyBatis-Plus、数据库驱动等依赖的版本为最新稳定版以获取已知漏洞的修复。同时将SQL注入检查纳入代码审查清单并可以使用SonarQube、Fortify等静态代码分析工具进行自动化扫描它们能有效识别出代码中${}的不当使用和字符串拼接SQL的模式。6.4 模糊查询的正确姿势模糊查询LIKE是一个需要特别留神的点。很多人会这样写qw.like(“name”, “%” userInput “%”);这是安全的因为MP会将整个“%” userInput “%”作为一个字符串参数传给#{}。但是如果你需要在XML中手动编写LIKE语句正确做法是if test“keyword ! null and keyword ! ‘‘“ AND (name LIKE CONCAT(‘%’, #{keyword}, ‘%’) OR email LIKE CONCAT(‘%’, #{keyword}, ‘%’)) /if绝对不要写成AND name LIKE ‘%${keyword}%’。7. 总结与核心心法回到最初的问题MyBatis-Plus如何解决SQL注入我的答案是它通过提供一套类型安全、便捷的API最大限度地引导开发者走向正确的参数化查询#{}道路但“解决”的前提是开发者必须遵循这套安全范式。你可以将MP的安全使用总结为以下几条核心心法默认安全坚信并坚持使用MP条件构造器的链式方法eq,gt,like等和Lambda表达式。这是最安全、最推荐的方式。红线意识将apply未使用占位符和last方法视为高危操作。任何用户输入都不得直接传入这两个方法。使用apply时必须配合{0}占位符。严守传统在自定义SQLXML或注解中坚决执行MyBatis的规则数据用#{}SQL关键字/结构用${}并极度谨慎。动态字段名、表名必须经过白名单校验。纵深防御不要依赖单一防线。结合输入校验、最小权限、日志管理、安全扫描构建从应用到数据库的多层防御体系。保持清醒不要因为使用了MP就放松对SQL注入的警惕。框架是工具安全的责任最终在于使用工具的人。定期回顾和审计代码中的SQL相关操作尤其是涉及动态拼接的地方。最后我个人最深刻的体会是安全往往不是被复杂的技术攻破的而是被“图省事”的心态和“我以为”的错觉所瓦解。在每一次调用apply或写下${}时多问自己一句“这个值用户能控制吗我百分之百确定吗” 这份审慎比任何高级框架都来得重要。