如何解决Spring Data JPA中的SQL注入问题:利用Query注解的命名参数

Query注解里用字符串拼接就是SQL注入温床
在 @Query 注解的 JPQL 或原生 SQL 字符串里,直接用 + 号拼接用户输入,这无异于把数据库的钥匙直接交给了请求参数。虽然 JPQL 不支持将预编译占位符(比如 ?1)与动态字段名或表名混合使用,但命名参数(:name)本身是安全的——前提是,别用它去拼接 SQL 语句的结构部分。
命名参数只能绑定值,不能绑定列名或表名
这里有个最常见的误区:以为写了 @Query("SELECT * FROM :table WHERE name = :name") 就算用了命名参数。实际上,:table 会被当作一个普通的字面量字符串处理,而不是进行参数替换,运行时要么直接报错,要么查不到任何数据。JPA 规范明确禁止在运行时解析动态的表名或列名。
:param只能出现在 WHERE、HA VING、ORDER BY(值部分)、GROUP BY(值部分)这类**值上下文**中。- 表名、列名、函数名、排序方向(ASC/DESC)这些结构部分,必须硬编码在 SQL 里,或者经过外部严格校验后,通过白名单机制进行拼接。
- 如果业务上确实需要动态表名,建议改用
JdbcTemplate配合白名单校验和PreparedStatement,别强行塞进@Query里。
安全写法:命名参数 + 严格类型约束 + 白名单兜底
下面这个例子看起来平平无奇,但每一步其实都在为安全加锁:
@Query("SELECT u FROM User u WHERE u.status = :status AND u.name LIKE %:name%")
List findUsers(@Param("status") Integer status, @Param("name") String name);
status参数是Integer类型,JPA 底层会自动将其转换为 JDBC 的setInt()调用,从根本上杜绝了字符串逃逸的可能。name参数虽然是String类型,但它只用于LIKE右侧的模糊匹配,而且通配符%是直接写死在 SQL 语句里的,并非从参数中拼接进来。- 如果业务要求按多个状态进行筛选,千万别写成
"status IN :statuses"(JPA 不支持直接将数组参数展开到 IN 子句中)。正确的做法是,动态构建 IN 子句,并配合一个经过白名单校验的枚举值列表。
原生 SQL 下命名参数仍安全,但得避开 CONCAT 和字符串函数
当使用 @Query(nativeQuery = true) 切换到原生 SQL 时,命名参数依然会走 JDBC 的预编译机制,安全性有保障。但需要注意的是,某些数据库函数可能会绕过参数化机制,带来隐患:
- 避免使用
CONCAT(:a, :b)或:col = :val这类将参数当作标识符来用的写法。 - 像 PostgreSQL 的
quote_ident(:table)函数,看起来能安全处理标识符,但 JPA 本身并不识别这个函数,传进去的仍然是一个未经转义的字符串。 - 真正需要动态字段时,稳妥的做法是:先用工具类(如
StringUtils.replaceChars(fieldName, ".", "_"))进行基础清洗,再与预设的白名单(例如Set.of("user_name", "created_at"))进行比对。如果不匹配,直接抛出IllegalArgumentException异常。
话说回来,最棘手的问题往往不在于参数怎么写,而在于团队中可能有人在 Service 层偷偷用 String.format 拼接好 SQL,再丢给 JdbcTemplate 去执行。这种代码在编译时不会报错,但只要参数里包含一个单引号或分号,就等于为攻击者敞开了大门。所以,盯紧日志里打印出的最终 SQL 语句,比任何编码规范都更管用。
