如何编写单元测试验证 SQL 注入防护

南乔
南乔
发布时间:2026-08-23 | 178人浏览过
原创

前言

很多团队会补 SQL 注入测试,但最后得到的只是“没报错”或“H2 报错了”这样的表面结果,真正的防护是否在业务层生效,反而没有被证明。本文把测试入口、异常断言、高危拼接分支、数据库误区和二次注入拆开讲清楚,帮你判断一条用例到底是在验证安全能力,还是只是在跑通一个看似正确的流程。

很多项目写了“SQL 注入防护测试”,结果测到的只是 mock 是否生效,或者数据库方言恰好报错。真正有价值的测试,应该让恶意输入穿过校验层、拼接逻辑和 DAO 调用,再明确判断它是否在进入数据库前被拦截。下面按测试入口、断言目标、高危分支、数据库误区和二次注入几个环节拆开讲,方便你据此补齐现有用例。

测试入口必须传入真实恶意字符串

不少用例之所以“通过”,不是因为防护有效,而是因为测试根本没有走到关键路径。比如在 service 层直接 mock userRepository.findByUsername 的返回值,却没有把 "admin'--" 真实传入方法体,这样测到的只是一个被伪造过的执行流程。

展示真实恶意输入如何穿过应用层,并在业务层被拦截的测试链路图
真实攻击载荷的测试链路把恶意输入真正送入方法调用链,才能测到防护逻辑是否生效。

有效的做法,是让输入真正穿过校验层、拼接逻辑和 DAO 调用,直到触发业务层异常,或者被确认安全执行。否则你验证的不是“防护是否生效”,而是“mock 是否写对了”。

  • 应该直接调用 userService.findByUsername("admin'--"),而不是只写 when(userRepository.findByUsername(any())).thenReturn(...)
  • 如果底层使用 MyBatis,要确认测试实际走的是 select * from users where name = #{name} 路径,而不是 ${name} 拼接分支
  • 对于 JSON 接口,测试请求体里应包含 %27 这类 URL 编码后的单引号,并确认解码后仍然会被拦截

断言目标应是业务异常,而不是“不报错”

很多人看到测试没报错,就以为防护生效了,这个判断并不成立。数据库驱动对非法输入的反应并不一致:MySQL 可能直接抛出 MySQLSyntaxErrorException,PostgreSQL 也可能静默截断或返回空结果。只看“有没有异常”,无法证明防护发生在数据库执行之前。

展示单元测试中正确断言目标与错误断言方式的对比信息图
断言方式:拦截成功与测试误判防护测试的核心不是“程序没崩”,而是业务层是否在数据库执行前主动拦截。

更可靠的断言方式,是明确要求抛出业务层异常,比如 IllegalArgumentException 或自定义的 InvalidInputException。这样才能说明拦截逻辑在应用层已经生效。

  • 正确示例:assertThatThrownBy(() -> service.login("root' OR '1'='1")) 配合 .isInstanceOf(IllegalArgumentException.class)
  • 错误示例:assertThatCode(() -> service.login("...")).doesNotThrowAnyException(),这只能说明程序没崩,不能说明请求没有进 DB
  • 如果实际抛出的是 PSQLException 或 SQLServerException,通常意味着防护失效,请求已经抵达数据库

高危分支要单独覆盖:${}、原生 SQL 与字符串拼接

真正危险的地方,往往不是常规参数绑定,而是那些绕开绑定机制的分支。MyBatis 的 ${}、JPA 的 createNativeQuery、Java 里的 String.format 和 + 拼接,都属于测试里必须显式覆盖的高风险写法。

展示危险 SQL 拼接方式、日志审计和二次注入覆盖范围的结构图
高危写法与延迟触发路径除了常规输入拦截,还要把动态拼接、审计日志和先存后查链路一起纳入测试。

尤其在旧项目中,拼接逻辑可能藏得很深。比如先从数据库取出模板 SQL,再把用户输入填入字段名或表名,这类场景即便表面上用了 #{},也不能说明整体链路是安全的。

  • 对于使用 ${tableName} 的 mapper,可传入 "users; DROP TABLE admins--",验证是否抛出预期异常
  • 对于 String sql = "SELECT * FROM " + userInput; 这类代码,测试应触发运行时异常,例如 SQLException,或者至少留下非法输入审计日志
  • 在 Hibernate 场景中,应检查是否使用了 @Param 绑定,而不是 createNativeQuery("SELECT * FROM users WHERE name = '" + input + "'")

不要把 H2 或 @Sql 当成防护测试本身

用内存数据库执行一条带 ' OR 1=1 的查询,再根据是否返回全表数据来判断安全性,这种思路看上去直观,实际上很容易误导。你测到的常常是 H2、hsqldb 或其他方言实现的 SQL 解析行为,而不是你在 Java 层写下的防护逻辑。

真正的防护通常发生在应用层,包括正则过滤、白名单校验和参数绑定调用。数据库只是最终执行者,不同数据库对非法输入的响应差异很大,把判断依据建立在 DB 是否报错上,结论往往不稳定。

  • 不建议依赖 @Sql(scripts = "malicious-data.sql") 这类测试方式,它无法证明你的校验逻辑是否真的触发
  • 不要把 H2 或 hsqldb 的“报错即拦截”当成安全结论,因为它们并不模拟 MySQL 的注释解析,也不等同于 PostgreSQL 的类型强转行为
  • 更值得检查的是审计日志里是否记录了 "Blocked SQL injection attempt: ..." 这类拦截信息

别漏掉最隐蔽的二次注入路径

很多测试只盯着“当前请求是否立即触发注入”,却忽略了二次注入。所谓二次注入,是指恶意内容先被保存,再在后续查询、拼接或动态 SQL 生成时触发风险。这类问题在单元测试里最容易被漏掉。

典型场景是:注册时写入 "admin'; --",系统当时并没有执行危险 SQL;但登录、检索或后台管理查询时,又把这个值拼进了 SQL 模板,于是漏洞在第二次使用时才暴露。

因此,测试设计不能只覆盖即时接口,还要补上“先存后查”的完整链路,并同时检查异常与日志记录是否符合预期。

本站声明

本文内容仅供参考,不代表本站观点。转载或引用请注明出处。如发现内容问题,请通过联系我们页面反馈。

Python 相关产品和应用

全部下载

专题列表