为什么前后端都要做数据校验?
表单页面上邮箱格式校验通过,并不意味着发到服务端的数据就干净——攻击者用 curl 或 Postman 直接调接口,前端校验形同虚设。前后端校验看似重复,实际各守一道完全不同的边界:前端管体验,后端管安全。
客户端校验:体验优化器,不是防线
浏览器里的校验价值在于即时反馈:必填项没填、手机号位数不对,用户敲完立刻知道,不用等一个网络来回。它还能拦掉大部分无心之失,减少发到后端的无效请求。
但它的前提是”用户按你设计的路径操作”。请求一旦发出浏览器,就不受你控制了:改 JS、绕过页面、直接构造 HTTP 报文,都是几秒钟的事。所以客户端校验只能定位为 UX 优化,任何一条”只有前端校验”的规则,都等于没有规则。
服务端校验:分三层设防
服务端要对所有入参保持”默认不可信”的态度,校验按层次展开,各层失败语义不同:
第一层:参数校验
检查格式与结构——字段是否存在、类型对不对、长度是否超限、枚举值是否在白名单内、数值范围是否合理。这一层不查数据库,开销极小,在请求入口就完成,失败直接返回 400。
第二层:业务校验
检查数据在当前业务语境下是否成立——优惠券是否过期、订单当前状态允不允许退款、库存够不够。这类规则往往要读库,且涉及并发,应在事务内”先锁定再判断”,防止”查的时候有库存、扣的时候没了”。
第三层:持久层约束
数据库的唯一索引、非空约束、外键是最后一道兜底。典型误区是”先 SELECT 判断用户名不存在再 INSERT”来保证唯一——并发下两个请求会同时通过检查。唯一性必须靠数据库唯一约束保证,代码捕获冲突异常转成友好提示。
Bean Validation:注解式参数校验
Java 生态的 Bean Validation 规范(Hibernate Validator 为参考实现)允许在 DTO 字段上声明式标注约束,Controller 里加 @Valid 触发:
public class RegisterRequest { |
|
几个容易踩的点:
@NotNull、@NotEmpty、@NotBlank语义递进:@NotBlank最严(去空白后非空),字符串字段基本都用它。- 校验
@PathVariable、@RequestParam这类裸参数时,必须在类上加@Validated,否则注解不生效。 - Spring Boot 2.3 之后需显式引入
spring-boot-starter-validation依赖。 - 校验失败抛
MethodArgumentNotValidException,用全局异常处理器统一转成带字段路径的错误响应,别把内部堆栈回给调用方。
校验与 SQL 注入:分工而非替代
常有人问”做了输入校验,是不是就不怕 SQL 注入了?”答案是否定的,两者解决的问题不同:
- **参数化查询(PreparedStatement)**是防 SQL 注入的根本手段。它把 SQL 结构和数据分开编译,用户输入永远只是”值”,不会改变语句结构。ORM(MyBatis 用
#{}而非${})默认就是参数化的。 - 输入校验是纵深防御的一层:白名单校验能提前挡掉明显恶意的载荷,减少攻击面,也能保证入库数据干净。
关键在不能反过来依赖:靠正则过滤单引号、OR、UNION 来防注入是黑名单思路,绕过手法层出不穷(编码变形、大小写、注释符)。正确姿势是强制参数化查询为主,白名单校验为辅。凡是无法参数化的场景(如动态排序字段名),必须用硬编码白名单映射,绝不拼接用户输入。
实践要点
- 白名单优于黑名单:声明”允许什么”而不是罗列”禁止什么”。
- 校验前先归一化(Unicode、大小写、全半角),防止用编码变形绕过规则。
- 错误提示对攻击者说得太细等于送信息:对外返回稳定错误码即可,细节记日志。
- 权限归属要在服务端查证:拿”当前登录用户 + 资源 ID”查库确认归属,不能信客户端传来的
userId。 - 把恶意输入、越权请求、并发重复提交写进自动化测试,否则校验规则会随着重构悄悄失效。
小结
- 客户端校验改善体验、减少无效请求,但可被轻易绕过,不构成安全边界。
- 服务端分层设防:参数校验管格式,业务校验管规则,数据库约束兜底唯一性。
- Bean Validation 用注解覆盖大部分格式校验,注意
@Validated与三个 Not 注解的语义差异。 - SQL 注入靠参数化查询根治,输入校验是纵深防御,二者分工不同、缺一不可。

