JPA命名查询的SQL注入风险,根源不在框架本身,而在于开发者对“参数化”的理解偏差。很多人以为只要用了@Query注解,把参数用冒号传进去就算参数化了,实际上远非如此。真正的问题出现在动态拼接JPQL、误用原生查询、以及混淆SpEL表达式与查询参数这三种场景。下面直接拆解这些风险点,并给出具体的防御写法。
JPQL参数绑定的正确姿势JPQL本身是面向对象的查询语言,它操作的是实体和属性,而不是数据库表。JPA实现层会自动将JPQL翻译成对应数据库的SQL,并在此过程中对参数值进行转义处理。关键是要区分两种参数绑定方式:命名参数和位置参数。命名参数以冒号开头,位置参数以问号加数字索引表示。无论哪种方式,只要参数值是通过setParameter方法传入的,JPA就会在生成的PreparedStatement中对值做安全处理。
错误的写法是把用户输入直接拼进JPQL字符串。比如下面这种代码,即使你用了@Query,只要参数是通过字符串拼接进去的,就完全绕过了参数化机制:
// 危险写法:字符串拼接
@Query("SELECT u FROM User u WHERE u.username = '" + username + "'")
User findByUsername(String username);
正确的做法是让参数独立于查询字符串,通过占位符引用:
// 安全写法:命名参数绑定
@Query("SELECT u FROM User u WHERE u.username = :username")
User findByUsername(@Param("username") String username);
这里有一个容易被忽略的细节:@Param注解不是可选的装饰,而是明确建立参数名与占位符之间映射关系的契约。如果你的Spring Data JPA版本在编译时保留了参数名信息,不加@Param也能工作,但显式声明可以避免因编译配置变化导致参数名丢失的问题。
原生查询的陷阱与防御当业务需要调用数据库特定函数或复杂SQL时,开发者会设置nativeQuery=true来使用原生SQL。这是SQL注入的高发区。很多人误以为原生查询里的冒号参数也会自动参数化,实际上JPA对原生SQL的处理方式取决于具体实现。Hibernate作为最常见的JPA实现,确实会对原生查询中的命名参数做参数化处理,但前提是你必须使用参数占位符,而不是手动拼接。
原生查询中另一个常见错误是动态排序。排序字段名和排序方向不能直接用参数绑定,因为参数化只能处理值,不能处理标识符(如表名、列名)。面对这种需求,必须对排序字段做白名单校验:
// 危险:直接拼接排序字段 @Query(value = "SELECT * FROM users ORDER BY " + sortField, nativeQuery = true) ListfindAllSorted(String sortField); // 安全:白名单校验 private static final Set ALLOWED_SORT_FIELDS = Set.of("id", "username", "email", "create_time"); public List findAllSorted(String sortField) { if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) { throw new IllegalArgumentException("Invalid sort field"); } return entityManager.createNativeQuery( "SELECT * FROM users ORDER BY " + sortField, User.class ).getResultList(); }
注意上面代码中,白名单校验后的sortField仍然是通过字符串拼接进入SQL的,但因为它的值已经被严格限定在预定义的集合内,攻击者无法注入恶意SQL片段。这是处理动态标识符的标准模式。
SpEL表达式与查询参数的边界Spring Data JPA的@Query注解支持SpEL表达式,这给查询带来了灵活性,但也制造了一个容易被利用的注入通道。SpEL表达式在查询执行前就被求值,其结果直接嵌入查询字符串,而不是作为参数传递。这意味着如果SpEL表达式中引用了来自用户输入的数据,就形成了注入点。
典型的危险场景是用SpEL引用实体名称或做条件判断时,间接引入了不可信数据。比如下面这种写法:
// 危险:SpEL中引用外部变量
@Query("SELECT u FROM User u WHERE u.role = :#{#role}")
List findByRole(String role);
这里的:#{#role}是SpEL占位符,它会在方法调用时被直接替换进JPQL字符串。虽然在这个简单例子中,如果role只是普通字符串,Hibernate可能会在后续处理中做一定程度的转义,但这种依赖框架隐式行为的做法极不可靠。正确的做法是坚持使用JPQL命名参数:
// 安全:使用JPQL命名参数
@Query("SELECT u FROM User u WHERE u.role = :role")
List findByRole(@Param("role") String role);
SpEL在@Query中的合理用途仅限于引用静态配置或实体类名,比如#{#entityName}来动态引用实体名称,或者访问Spring Bean中的常量方法。任何来自用户输入的数据都不应该进入SpEL表达式。
Criteria API的天然免疫与使用成本JPA的Criteria API从设计上就杜绝了字符串拼接式的查询构建。它通过类型安全的元模型来构造查询条件,所有参数值都通过ParameterExpression对象传递,底层自动走PreparedStatement。如果你的项目对动态查询需求频繁,且对安全性要求极高,Criteria API是比@Query更稳妥的选择。
一个常见的误区是认为Criteria API写起来太繁琐。实际上结合JPA静态元模型,代码可以很简洁:
// 使用Criteria API构建安全查询 CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuerycq = cb.createQuery(User.class); Root root = cq.from(User.class); List predicates = new ArrayList<>(); if (username != null && !username.isEmpty()) { predicates.add(cb.equal(root.get(User_.username), username)); } if (role != null && !role.isEmpty()) { predicates.add(cb.equal(root.get(User_.role), role)); } cq.where(predicates.toArray(new Predicate[0])); return entityManager.createQuery(cq).getResultList();
这段代码中,所有用户输入都通过cb.equal方法传入,底层生成的是标准的参数化SQL。即使攻击者在username中传入' OR '1'='1,也只会被当作普通字符串值进行匹配,不会改变SQL的语义结构。
存储过程调用的参数化要点JPA调用存储过程时,通过StoredProcedureQuery注册参数的方式天然支持参数化。但问题出在存储过程内部。如果你的存储过程内部使用了动态SQL拼接(比如MySQL的PREPARE语句或SQL Server的sp_executesql),而拼接的字符串又来自外部传入的参数,那么即使JPA侧做了参数化,攻击载荷仍然可以在存储过程内部形成二次注入。
防御策略是在存储过程内部同样坚持参数化原则。以MySQL为例,如果必须使用动态SQL,应该用PREPARE配合占位符,而不是直接拼接用户输入:
-- 存储过程内部的安全动态SQL
CREATE PROCEDURE search_users(IN search_term VARCHAR(100))
BEGIN
SET @sql = 'SELECT * FROM users WHERE username LIKE ?';
SET @search = CONCAT('%', search_term, '%');
PREPARE stmt FROM @sql;
EXECUTE stmt USING @search;
DEALLOCATE PREPARE stmt;
END
JPA侧调用时只需正常注册参数即可,不需要额外处理。
批量操作中的参数化注意事项JPA的批量更新或删除操作同样支持参数化,但很多开发者为图方便会使用字符串拼接来构造IN子句。比如根据一组ID批量查询时,常见的错误写法是:
// 危险:动态拼接IN子句
String jpql = "SELECT u FROM User u WHERE u.id IN (" + ids.stream()
.map(String::valueOf).collect(Collectors.joining(",")) + ")";
正确的做法是利用JPA的参数绑定,为每个ID创建一个命名参数:
// 安全:逐个参数绑定
StringBuilder jpql = new StringBuilder("SELECT u FROM User u WHERE u.id IN (");
Map paramMap = new HashMap<>();
for (int i = 0; i < ids.size(); i++) {
String paramName = "id" + i;
jpql.append(i > 0 ? ", :" : ":").append(paramName);
paramMap.put(paramName, ids.get(i));
}
jpql.append(")");
TypedQuery query = entityManager.createQuery(jpql.toString(), User.class);
paramMap.forEach(query::setParameter);
return query.getResultList();
虽然代码量增加了,但每个ID值都通过setParameter传入,确保了参数化覆盖所有用户输入。如果你的JPA实现支持,也可以使用setParameterList这样的批量参数方法,效果相同。
框架层面的防御配置除了代码层面的规范,还可以在框架配置上加一道防线。Hibernate提供了hibernate.query.fail_on_pagination_over_collection_fetch和hibernate.query.plan_cache_max_size等参数来约束查询行为,但更直接的安全配置是开启SQL语句日志,配合WAF规则检测异常SQL模式。
在Spring Boot中,可以通过以下配置记录所有SQL语句及其参数值,便于审计和发现潜在注入尝试:
# application.yml
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
这个配置会让Hibernate输出完整的SQL语句和绑定的参数值。如果日志中出现了明显的注入载荷,说明某处代码存在漏洞。生产环境中建议将日志级别调回INFO,避免性能影响和敏感数据泄露。
代码审查中的快速检查清单在日常代码审查中,可以重点关注以下几个模式来快速识别潜在的注入风险:
第一,搜索@Query注解中是否包含字符串拼接操作符(加号、concat方法)。如果有,几乎可以确定存在注入风险。
第二,检查所有nativeQuery=true的查询方法,确认其中的参数是否全部通过占位符绑定,排序字段和表名是否做了白名单校验。
第三,审查SpEL表达式中是否引用了方法参数或请求上下文中的变量。SpEL应该只用于访问静态配置和实体元信息。
第四,查看所有直接使用EntityManager或JdbcTemplate创建查询的地方,这些位置绕过了Spring Data JPA的自动参数化,需要人工确认参数绑定是否正确。
第五,关注存储过程调用,检查对应的数据库存储过程内部是否存在动态SQL拼接。这个需要DBA和开发人员协作审查。
参数化不是银弹但不可或缺参数化查询能防御的是通过篡改SQL语法结构来改变查询语义的攻击方式。但它不能解决所有安全问题。比如通过参数传入超长字符串导致数据库性能下降,或者利用LIKE查询配合通配符进行资源耗尽攻击,这些都需要额外的输入验证和速率限制来应对。
一个完整的防注入策略应该是三层防护:代码层坚持参数化查询,框架层开启SQL日志监控,网络层部署WAF检测异常请求模式。参数化是其中最核心也最有效的一环,它从根源上切断了注入攻击的通道。只要你在每一次写查询代码时,都问自己一句“这个值是通过setParameter传进去的,还是拼进字符串的”,就能避免绝大多数SQL注入漏洞。
