如何在 Java 8 项目中防止 SQL 注入

Alice
Alice
发布时间:2026-08-25 | 89人浏览过
原创

前言

很多 Java 8 项目并不是完全没做防护,而是把注意力放错了位置:一边做关键词过滤,一边还在拼接 SQL,结果真正的注入入口仍然敞开。要把这类问题收住,关键不在“补更多规则”,而在于把 SQL 结构、参数绑定、框架占位符和错误输出这几层边界一次性划清。

很多 Java 8 项目并不是完全没做防护,而是把注意力放错了位置:一边做关键词过滤,一边还在拼接 SQL,结果真正的注入入口仍然敞开。要把这类问题收住,关键不在“补更多规则”,而在于把 SQL 结构、参数绑定、框架占位符和错误输出这几层边界一次性划清。

这篇文章按开发中最常见的四个落点来拆解:JDBC 层怎样正确使用 PreparedStatement,MyBatis 里 #{} 和 ${} 该怎么分工,异常信息为什么不能原样外露,以及输入验证到底该拦什么、不该拦什么。读完之后,你可以直接判断一段 Java 8 数据访问代码是否存在明显注入风险。

PreparedStatement 为什么是第一道硬性防线

在 Java 8 中,PreparedStatement 不是“推荐使用”,而是防 SQL 注入时必须落实的基础要求。它的核心作用,是在预编译阶段先固定 SQL 的语法结构,再把用户输入作为参数值单独绑定进去。这样一来,哪怕传入的是 ' OR 1=1 --,数据库也只会把它当作普通字符串,而不会当成 SQL 语句的一部分去解析。

展示 PreparedStatement 如何将 SQL 结构与参数值分离,以及常见错误写法与安全写法的对比。
PreparedStatement 与字符串把 SQL 骨架和用户输入拆开,是 Java 8 项目防注入最基础也最关键的一步。

真正危险的写法,通常是直接使用 Statement 做字符串拼接,例如:

SELECT * FROM user WHERE id = " + userId

这类代码看上去很直白,但风险也最直接。即使开发者认为 userId 应该是数字,攻击者仍可能通过构造类似 1; DROP TABLE user; 的输入,尝试触发堆叠查询或其他非预期执行路径。

正确做法有几条不能退让:

  • 统一使用 ? 作为占位符,再通过 pstmt.setString(1, value)、pstmt.setLong(2, id) 等方法完成绑定。
  • 参数不能省略、不能错位,也不能“先拼一半、再绑定一半”。只要用户输入进入 SQL,就应该走参数绑定。
  • 批量写入或批量更新时,优先采用 addBatch() + executeBatch(),避免在循环里反复 prepare,既更稳妥,也更符合性能习惯。
  • 不要在占位符之外再做所谓“手动转义”,例如先调用 StringEscapeUtils.escapeSql() 再拼接 SQL。这种做法既不能替代参数化,反而可能让代码产生误判,甚至引入新的漏洞。

MyBatis 里 #{} 和 ${} 的边界要写死

很多项目用了 MyBatis 之后,会误以为“框架已经帮我处理安全问题”。这只对了一半。MyBatis 是否安全,关键取决于你用的是 #{} 还是 ${}。

展示 MyBatis 中 #{} 与 ${} 的用途边界,突出值参数与元数据位置的不同处理方式。
MyBatis 占位符的安全边界MyBatis 是否安全,不取决于是否用了框架,而取决于参数最终是绑定还是替换。

#{} 的行为,本质上等价于 PreparedStatement 的参数绑定,因此适合承接所有值类型参数;而 ${} 是纯文本替换,效果与字符串拼接没有本质区别,只要进入用户可控内容,就会把风险直接带进 SQL。

可以这样理解它们的分工:

  • #{} 用于普通查询条件和值参数,例如 WHERE name = #{name}、AND status IN (#{status1}, #{status2})。
  • ${} 只能用于无法参数化的元数据位置,例如表名、列名或 ORDER BY 字段,而且必须配套白名单校验。

例如下面这种写法,只有在 tableName 已经被明确限制在固定集合时才可接受:

if (!List.of("user", "order", "product").contains(tableName)) throw new IllegalArgumentException();

也就是说,像 SELECT * FROM ${tableName} 这种场景,本身不是绝对禁止,但前提必须是“来源可控、范围固定、校验先行”。

需要特别强调的是,${} 绝不能出现在 WHERE、VALUES 或任何会承接用户输入值的位置。只要把用户提供的名称、状态、关键词、ID 或时间条件通过 ${} 填进 SQL,风险级别就和手写拼接基本一致。

异常信息不脱敏,等于把库表线索直接暴露出去

防注入不只是“别让恶意 SQL 执行成功”,还包括“别在失败时把数据库细节送给攻击者”。实际项目里,很多问题不是因为第一轮注入就得手,而是因为报错信息把表名、字段名、约束名和数据库类型都透露了出去。

SQLException.getMessage() 往往就包含这类敏感内容。比如 MySQL 可能返回 Unknown column 'pwd_hash' in 'where clause',PostgreSQL 可能报出 relation "users_backup" does not exist。对攻击者而言,这些信息足够用来摸清表结构和命名习惯。

因此,生产环境至少要做到三件事:

  • HTTP 响应统一返回业务可读、技术细节不可见的信息,例如 {"code": 500, "message": "操作失败,请稍后重试"}。
  • 日志里优先记录 e.getSQLState() 和 e.getErrorCode()。例如 23505 可以表示唯一约束冲突,这类信息足够排查,又不会把完整上下文暴露出去。
  • 不要把 e.printStackTrace() 输出到公开可访问的日志、监控页面或 API 返回体中,尤其不能让调用方直接看到数据库错误原文。

这里的重点不是“完全不记日志”,而是把日志分层:对外只给稳定、模糊、可恢复的提示,对内保留足够的错误码和必要上下文,供开发和运维排查。

输入验证是前置闸机,但不能替代参数化

输入验证经常被误用成“防注入主方案”,这会把职责搞反。验证的主要价值,不是把 SQL 变安全,而是尽早拦下明显不合法的请求,减少异常进入数据库层的机会,并且约束那些本来就不该出现的输入形态。

展示异常脱敏与输入验证在数据库访问前后的分工,说明两者不能替代参数化。
输入验证与异常脱敏的分工输入验证负责拦无效请求,异常脱敏负责截断信息泄露,二者都属于数据库安全的外围防线。

因此,它和 PreparedStatement 是两层独立防线:前者负责减少脏输入,后者负责隔离 SQL 结构。两者都要做,但任何一层都不能代替另一层。

有效的输入验证,重点在语义约束,而不是关键词过滤:

  • ID、主键、分页页码这类天然有类型约束的参数,应该直接做类型转换,例如 Long.parseLong(idStr)。一旦抛出 NumberFormatException,就应在业务层直接拒绝,而不是继续交给 JDBC。
  • 邮箱、手机号等格式明确的字段,适合使用正则白名单,例如 ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$,并且不能只依赖前端 JavaScript 校验。
  • 分页参数必须设置硬上限,例如 Math.min(pageSize, 100),避免有人用异常大值拖垮查询和数据库资源。
  • 不要启用全局“SQL 关键词黑名单”,比如统一拦截 union select drop。这类规则既容易被绕过,也可能误伤正常业务内容,例如用户名、备注或搜索词中恰好包含 select。

换句话说,好的校验是判断“这个字段在业务上是否合理”,而不是猜测“它是不是像一段攻击语句”。前者可维护,后者通常只会越补越乱。

项目里最容易漏掉的风险点

很多 Java 8 项目出问题,并不是完全没有使用参数化,而是局部代码破坏了整体规则。只要某一个接口里混入一次手写 Statement,或者某一段 MyBatis 动态 SQL 错用了 ${},前面做过的防护就会被单点击穿。

如果你要快速排查现有项目,可以优先看这几类位置:

  • 搜索是否存在 Statement、createStatement() 和明显的 SQL 字符串拼接。
  • 检查 MyBatis XML 或注解 SQL 中是否把 ${} 用在查询条件和值参数上。
  • 确认异常处理中是否把 SQLException.getMessage()、完整堆栈或数据库报错原文直接返回给前端。
  • 检查入口参数是否有类型约束、格式校验和分页上限,避免无效请求直接打到数据库。

最危险的误区,是以为“项目用了 MyBatis,所以天然安全”。实际上,安全性从来不是框架自动兜底的结果,而是每一层写法都没有越界。只有把参数绑定、占位符边界、错误脱敏和输入校验同时落实,SQL 注入风险才算真正被压到可控范围内。

相关标签:

本站声明

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

Python 相关产品和应用

全部下载

专题列表