JPA原生查询中最危险的写法,就是直接用字符串拼接把用户输入塞进SQL语句。这种代码一旦上线,攻击者可以通过输入特殊构造的参数,篡改SQL逻辑,窃取数据甚至删除整个表。很多开发者以为用了JPA就天然安全,实际上当你写下createNativeQuery并手动拼接条件时,安全防线就已经出现裂缝。修复方案不是抛弃原生查询,而是强制使用参数占位符,让JPA底层通过PreparedStatement机制对参数值进行转义和类型处理,从根源上切断注入路径。

为什么字符串拼接是SQL注入的元凶

SQL注入的本质是攻击者输入的数据被数据库当作SQL代码执行。当你用加号把用户输入和SQL骨架粘在一起,比如"SELECT * FROM users WHERE username = '" + username + "'",攻击者输入admin' --,最终SQL变成SELECT * FROM users WHERE username = 'admin' --',后面的单引号被注释掉,查询逻辑被彻底改写。JPA的createNativeQuery方法本身不会自动防护,它只是把拼接好的字符串原样发给JDBC驱动。如果字符串是在Java代码里拼完再传进去的,占位符机制根本没机会介入,所有防护都形同虚设。

参数占位符的两种正确写法

JPA原生查询支持两种占位符:命名参数和位置参数。命名参数以冒号开头,比如:username,位置参数用问号加数字索引,比如?1。两种方式都能触发参数化查询,区别在于可读性和维护性。命名参数在SQL复杂时更清晰,位置参数在参数极少时更简洁。关键点是无论选哪种,参数值都必须通过setParameter方法传入,绝对不能手动拼进SQL字符串。

// 正确:命名参数
String sql = "SELECT u.id, u.username FROM users u WHERE u.username = :username AND u.status = :status";
Query query = entityManager.createNativeQuery(sql);
query.setParameter("username", username);
query.setParameter("status", status);

// 正确:位置参数
String sql = "SELECT u.id, u.username FROM users u WHERE u.username = ?1 AND u.status = ?2";
Query query = entityManager.createNativeQuery(sql);
query.setParameter(1, username);
query.setParameter(2, status);
错误示范:这些写法必须彻底杜绝

第一种典型错误是直接用字符串拼接把参数值嵌入SQL,然后传给createNativeQuery。这种代码在代码审查中应该直接拒绝通过。第二种更隐蔽的错误是虽然用了占位符,但部分条件仍然用拼接方式处理,比如动态表名或排序字段。如果业务确实需要动态表名,必须用白名单校验,不能直接把用户输入作为标识符。第三种错误是用String.format或MessageFormat构造SQL,本质上和加号拼接没有区别,同样绕过了参数化机制。第四种错误是在存储过程调用中拼接参数,即使调用的是存储过程,如果参数是拼进去的,注入风险依然存在。

// 危险:字符串拼接
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
Query query = entityManager.createNativeQuery(sql);

// 危险:即使用了占位符,但部分条件仍然拼接
String sql = "SELECT * FROM " + tableName + " WHERE id = :id";

// 危险:String.format构造SQL
String sql = String.format("SELECT * FROM users WHERE username = '%s'", username);

// 危险:存储过程参数拼接
String sql = "CALL get_user('" + userId + "')";
动态排序和动态表名的安全处理

参数占位符只能绑定值,不能绑定标识符,这是SQL规范的限制,不是JPA的缺陷。表名、列名、排序关键字这类标识符如果必须动态传入,唯一安全的做法是服务端维护白名单。拿到用户输入后,先判断是否在白名单内,不在就直接拒绝或使用默认值。白名单要硬编码在代码里,不要从数据库或配置文件动态读取,减少被篡改的可能。排序方向通常只有ASC和DESC两个合法值,用枚举约束即可,不需要复杂逻辑。

// 白名单校验动态排序
private static final Set ALLOWED_SORT_COLUMNS = Set.of("id", "username", "create_time", "status");
private static final Set ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");

public List findUsersByOrder(String sortBy, String sortDirection) {
    if (sortBy == null || !ALLOWED_SORT_COLUMNS.contains(sortBy)) {
        throw new IllegalArgumentException("Invalid sort column: " + sortBy);
    }
    if (sortDirection == null || !ALLOWED_DIRECTIONS.contains(sortDirection.toUpperCase())) {
        throw new IllegalArgumentException("Invalid sort direction: " + sortDirection);
    }
    String sql = "SELECT * FROM users ORDER BY " + sortBy + " " + sortDirection.toUpperCase();
    return entityManager.createNativeQuery(sql, User.class).getResultList();
}
IN查询的参数占位符处理技巧

IN查询是原生SQL中比较容易踩坑的场景。参数占位符不能直接绑定一个列表,因为每个问号只对应单个值。如果列表长度固定,可以手动写多个占位符然后逐个setParameter。如果长度不固定,需要动态生成占位符字符串,但注意这里动态生成的是占位符本身,不是参数值,参数值仍然通过setParameter安全绑定。Spring Data JPA环境下可以用JdbcTemplate或NamedParameterJdbcTemplate,它们对IN查询的支持更友好。

// 动态生成IN查询占位符
List userIds = Arrays.asList(1L, 2L, 3L);
String placeholders = userIds.stream()
        .map(id -> "?")
        .collect(Collectors.joining(", "));
String sql = "SELECT * FROM users WHERE id IN (" + placeholders + ")";
Query query = entityManager.createNativeQuery(sql, User.class);
for (int i = 0; i < userIds.size(); i++) {
    query.setParameter(i + 1, userIds.get(i));
}
实体映射与结果集安全返回

原生查询返回结果时,尽量指定实体类或结果集映射,避免使用无类型的原始结果集然后手动转换。createNativeQuery(String sql, Class entityClass)会按照实体注解自动映射字段,减少手动处理过程中引入的二次注入风险。如果查询涉及多表关联或聚合函数,用@SqlResultSetMapping定义好映射关系,保持代码清晰可控。不要为了省事把查询结果拼成HTML或JSON直接返回给前端,输出编码和转义是另一道防线,但绝不能替代输入侧的参数化防护。

// 指定实体类自动映射
String sql = "SELECT u.id, u.username, u.email FROM users u WHERE u.id = :id";
Query query = entityManager.createNativeQuery(sql, User.class);
query.setParameter("id", userId);
List users = query.getResultList();

// 使用@SqlResultSetMapping处理复杂结果
@SqlResultSetMapping(
    name = "UserSummaryMapping",
    classes = @ConstructorResult(
        targetClass = UserSummary.class,
        columns = {
            @ColumnResult(name = "id", type = Long.class),
            @ColumnResult(name = "username", type = String.class),
            @ColumnResult(name = "order_count", type = Integer.class)
        }
    )
)
JPA实现差异与注意事项

Hibernate作为最常用的JPA实现,对原生查询的参数绑定有额外优化,但基本行为遵循JPA规范。EclipseLink在处理命名参数时对大小写敏感,而Hibernate不敏感,跨实现迁移时要注意统一风格。参数占位符不能出现在SQL字符串的引号内部,否则会被当作字面量处理。对于数据库函数调用,参数仍然可以绑定,比如SELECT * FROM users WHERE DATE(create_time) = :date,这里的:date就是安全的参数占位符。如果数据库函数需要拼接标识符,同样要回到白名单方案。

代码审查中的快速检查清单

审查包含原生查询的代码时,重点检查三个地方。第一,createNativeQuery的参数是否包含变量,如果整个SQL字符串是常量,风险极低;如果包含变量,必须确认变量是占位符还是拼接值。第二,setParameter调用是否覆盖了所有用户可控的输入,有没有遗漏的条件分支。第三,动态标识符是否经过白名单校验,校验逻辑是否硬编码且不可绕过。只要这三项检查全部通过,SQL注入风险就能控制在极低水平。任何一项不满足,代码必须打回修改。

参数占位符的性能优势

除了安全层面,参数化查询还能带来显著的性能提升。数据库对参数化SQL可以缓存执行计划,相同的SQL模板只需解析一次,后续请求直接复用。如果用字符串拼接,每个不同的参数值都会产生新的SQL文本,数据库需要重新解析和优化,CPU开销和响应延迟都会增加。在高并发场景下,这个差异会被放大数十倍。安全方案恰好也是性能最优方案,这种双重收益没有理由不用。

框架层面的统一防护策略

单靠开发者自觉遵守规范不够可靠,需要在框架层面设置护栏。可以封装统一的原生查询工具类,强制所有原生SQL必须通过工具类执行,工具类内部只暴露参数绑定接口,不开放字符串拼接入口。在CI流水线中集成静态代码扫描,检测createNativeQuery调用时SQL字符串是否包含变量拼接,发现违规直接阻断构建。代码审查模板中把SQL注入检查列为必检项,形成制度约束。三层防护叠加,才能把人为疏漏的概率降到最低。

参数占位符不是可选的编码风格,而是原生查询的唯一正确写法。任何绕过占位符直接拼接SQL的做法,都是在代码里埋雷。修复成本极低,只需改写查询构造方式,不需要引入额外依赖,不需要修改数据库结构。把这条规范写进团队编码标准,落实到每一次代码提交和审查中,SQL注入这类低级漏洞就再难有生存空间。