Spring Data JPA 的查询防注入,核心问题不在于 JPA 本身不安全,而在于开发者经常在它提供的安全抽象之上,亲手打开后门。很多人以为用了 JPA 就不用管 SQL 注入了,结果在拼接 JPQL 或者滥用原生查询时,把参数直接嵌进了查询字符串,漏洞就这么产生了。真正要防的,是你自己绕过框架安全机制的那些操作。
JPA 的安全底线在哪里JPA 的 EntityManager 和 Spring Data 的衍生查询方法,底层都是用参数化查询实现的。比如你写一个 findByUsername(String username),Spring Data 会自动把它转成 WHERE username = ?,然后通过 PreparedStatement 绑定参数。这个过程中,参数值永远不会被当作 SQL 指令的一部分来解析,所以注入无从发生。问题出在当你手动写 JPQL 或者 SQL 的时候,用了字符串拼接而不是参数绑定,等于把安全机制短路了。
JPQL 拼接是最常见的坑很多项目里能看到这样的代码:
String jpql = "SELECT u FROM User u WHERE u.username = '" + username + "'"; Query query = entityManager.createQuery(jpql);
这跟当年用 JDBC 拼字符串没有任何区别。攻击者输入 admin' OR '1'='1,查询逻辑就被篡改了。正确做法是使用命名参数或者位置参数:
String jpql = "SELECT u FROM User u WHERE u.username = :username";
Query query = entityManager.createQuery(jpql);
query.setParameter("username", username);
JPA 的参数绑定机制会确保传入的值被正确转义,从根本上杜绝注入。位置参数用 ?1、?2 同理,但命名参数可读性更好,推荐优先使用。
原生查询是重灾区当你不得不写原生 SQL 时,风险成倍增加。EntityManager.createNativeQuery 同样支持参数绑定,但很多开发者图省事,直接拼字符串。更隐蔽的是,有些人在原生查询里用占位符,却用 String.format 把参数填进去,这等于没做任何防护。原生查询的参数绑定写法如下:
String sql = "SELECT * FROM users WHERE username = :username";
Query query = entityManager.createNativeQuery(sql, User.class);
query.setParameter("username", username);
注意,这里用的是 JPA 的命名参数语法,而不是 JDBC 的问号占位符。JPA 会负责把命名参数转换成底层 PreparedStatement 的占位符,安全性和便利性兼得。
Spring Data JPA 的 @Query 注解同样需要警惕在 Repository 接口里用 @Query 定义查询很常见。只要你用冒号参数的形式,就是安全的:
@Query("SELECT u FROM User u WHERE u.username = :username")
User findByUsername(@Param("username") String username);
但如果你在 @Query 里用 SpEL 表达式或者字符串拼接动态构建查询,就要小心了。尤其是结合原生查询时,有些开发者会用 ? 占位符但忘记 Spring Data 默认是按位置绑定的,导致参数错位或直接拼接。记住一个原则:永远不要在查询字符串里看到加号连接参数值。
动态查询的防注入策略复杂业务场景下,查询条件往往是动态组合的。这时候很多人会掉进拼接的陷阱。安全的做法有三种。第一种是用 Criteria API,它是 JPA 提供的类型安全的动态查询构建器:
CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuerycq = cb.createQuery(User.class); Root root = cq.from(User.class); List predicates = new ArrayList<>(); if (username != null) { predicates.add(cb.equal(root.get("username"), username)); } cq.where(predicates.toArray(new Predicate[0]));
Criteria API 的所有条件都是通过方法调用构建的,参数自动绑定,完全不存在字符串拼接,因此天然防注入。缺点是代码冗长,可读性稍差。
第二种是 Spring Data 的 Specification 接口,它本质上是 Criteria API 的封装,写起来更符合 Spring 风格:
Specificationspec = (root, query, cb) -> { List predicates = new ArrayList<>(); if (username != null) { predicates.add(cb.equal(root.get("username"), username)); } return cb.and(predicates.toArray(new Predicate[0])); }; List users = userRepository.findAll(spec);
第三种是 Querydsl,它提供了一套流畅的 API 来构建类型安全的查询,编译期就能检查字段名是否正确,安全性同样有保障。无论选哪种,核心思想都是把查询结构的构建和参数值的绑定分开,让框架处理参数转义。
LIKE 查询的注入风险LIKE 模糊查询容易被忽视。即使你用了参数绑定,如果不对用户输入的通配符做处理,攻击者可以注入 % 或 _ 来改变查询意图,比如让一个本应精确匹配的查询变成全表扫描,造成性能攻击。更危险的是,有些数据库允许在 LIKE 模式里嵌入逻辑条件,虽然现代数据库对此已有防护,但仍需谨慎。建议在应用层对用户输入的通配符进行转义:
String escaped = username.replace("\\", "\\\\")
.replace("%", "\\%")
.replace("_", "\\_");
query.setParameter("username", "%" + escaped + "%");
同时在 JPQL 里使用 ESCAPE 子句指定转义字符,确保数据库也按你的预期处理。
IN 子句的参数绑定陷阱IN 查询需要传入一个集合,很多开发者会犯这样的错误:
String ids = "1,2,3";
String jpql = "SELECT u FROM User u WHERE u.id IN (" + ids + ")";
这等于把参数直接拼接进去了。正确做法是利用 JPA 的参数绑定支持集合类型:
ListidList = Arrays.asList(1L, 2L, 3L); String jpql = "SELECT u FROM User u WHERE u.id IN :ids"; query.setParameter("ids", idList);
JPA 会自动展开集合,生成正确的 IN 子句并绑定每个参数。这个特性很多开发者不知道,导致写出有漏洞的代码。
排序字段和表名的注入防护参数绑定只能保护值类型的参数,对于排序字段名、排序方向、表名这类标识符,JPA 无法通过参数绑定处理。如果你的接口允许前端传排序字段,必须做白名单校验:
private static final SetALLOWED_SORT_FIELDS = Set.of("id", "username", "email", "createTime"); public List findUsersByOrder(String sortField) { if (sortField == null || !ALLOWED_SORT_FIELDS.contains(sortField)) { throw new IllegalArgumentException("Invalid sort field"); } // 此时可以安全地拼接到 JPQL 或 Criteria 中 }
绝对不要直接把用户输入的字段名拼进查询字符串。表名同理,如果确实需要动态表名,必须用白名单或者映射表来控制,没有例外。
存储过程调用的安全考量JPA 调用存储过程时,参数绑定同样适用。用 StoredProcedureQuery 注册参数并设置值,不要自己拼 CALL 语句。存储过程内部也要遵循参数化原则,避免在过程体内拼接动态 SQL,否则即使 JPA 层面安全,数据库层面仍然有注入风险。
ORM 框架不是银弹JPA 和 Spring Data 提供了强大的安全基础,但前提是你得按规矩用。任何绕过参数绑定机制的写法,都是在给自己挖坑。代码审查时,看到 createQuery 或 createNativeQuery 里的字符串拼接,就应该直接标记为阻塞项。同时,静态代码分析工具可以辅助检测这类模式,但最终责任在开发者对安全机制的理解和遵守。
防注入的本质不是记住某个框架的特定写法,而是理解参数化查询的原理,并始终把用户输入当作不可信数据来对待。只要你的查询字符串里永远不直接包含用户输入的值,注入就无从下手。JPA 给了你所有需要的工具,剩下的就是别自己把门打开。
