Java 业务安全设计:从不信任客户端到幂等、注入防护与敏感数据保护

很多业务安全事故并不是因为缺少复杂的密码学算法,而是因为最基本的边界没有守住:服务端相信了客户端计算结果,把外部输入拼进了 SQL 或脚本,把用户 ID 当成普通参数接收,资金操作没有全链路幂等,敏感数据又以不合适的方式保存。本文从 Java 业务开发视角重新组织这些问题,建立一条从请求入口、身份与权限、资源防刷、资金幂等、注入防护,到密码、隐私数据和 TLS 传输的完整安全链路,并结合现代 Spring Boot、JDK 与 TLS 的版本变化说明哪些旧做法今天已经不再适用。

安全真正从“信任边界”开始

业务系统里最重要的一条安全原则,不是“把参数校验写得更严”,而是先回答一个问题:哪些数据属于系统自己的事实,哪些数据只是外部参与者提交的意图?

浏览器、App、小程序、第三方调用方,甚至经过前端页面约束后的请求,本质上都处在服务端的信任边界之外。客户端可以被修改,请求可以脱离 UI 直接构造,请求头可以伪造,隐藏字段也可以修改。因此,服务端不能把“客户端能提交这个字段”理解为“客户端有资格决定这个字段”。

一个更准确的模型是:

flowchart LR
    C[Client / App / Browser] -->|提交操作意图| G[Gateway / Web Layer]
    G --> A[Authentication]
    A --> V[Validation & Authorization]
    V --> B[Business Service]
    B --> D[(Authoritative Data)]
    B --> X[External Services]

    C -. 不可信 .-> G
    D -. 服务端事实来源 .-> B

客户端负责表达“我要买哪个商品、买多少”“我要修改哪个地址”“我要执行哪项操作”;商品价格、订单金额、账户余额、优惠金额、订单状态、当前用户身份等真正决定业务结果的数据,应由服务端根据权威数据重新计算或读取。

客户端只提交意图,服务端维护最终状态

最典型的例子是下单。页面为了展示订单,当然会知道商品单价,也可以在前端计算总价。但这只是交互层的计算结果,不能成为最终入账依据。

接口设计上,最好直接把“客户端有权决定的字段”收敛成 Request DTO,而不是把完整领域对象暴露给客户端:

1
2
3
4
public record CreateOrderRequest(
@NotNull Long itemId,
@Positive int quantity
) {}

服务端收到请求后重新查询商品并计算金额:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
@RequiredArgsConstructor
public class OrderService {

private final ItemRepository itemRepository;
private final OrderRepository orderRepository;

@Transactional
public Order create(CreateOrderRequest request, Long currentUserId) {
Item item = itemRepository.findById(request.itemId())
.orElseThrow(() -> new IllegalArgumentException("商品不存在"));

BigDecimal total = item.getPrice()
.multiply(BigDecimal.valueOf(request.quantity()));

Order order = new Order();
order.setUserId(currentUserId);
order.setItemId(item.getId());
order.setItemPrice(item.getPrice());
order.setQuantity(request.quantity());
order.setTotalPrice(total);
return orderRepository.save(order);
}
}

如果前端同时提交了“展示时看到的价格”,它可以用于做价格变化提示,但不能覆盖服务端查询出的价格。这个区别很重要:客户端计算结果可以参与校对,不能参与裁决。

这种原则不只适用于价格。库存是否足够、优惠券是否可用、当前状态是否允许流转、用户是否拥有目标资源,都应该以服务端的实时状态为准。

UI 限制不是安全限制

页面下拉框只显示三个国家,并不意味着接口只能收到这三个值。攻击者不需要通过页面操作,可以直接构造 HTTP 请求。

因此,参数合法性必须是 API 契约的一部分,而不是 UI 的附带效果。Spring Boot 3 使用 jakarta.validation 可以把基础约束直接放到 DTO 上:

1
2
3
4
5
6
7
8
9
public record RegisterRequest(
@NotBlank String username,
@Min(1) @Max(3) Integer countryId
) {}

@PostMapping("/register")
public void register(@Valid @RequestBody RegisterRequest request) {
registrationService.register(request);
}

但注解校验只能处理“长度、范围、格式”这类通用约束。像“这个国家当前是否开放注册”“这个优惠券是否属于当前活动”“这个资源是否归当前用户所有”,仍然必须进入业务层做白名单、状态和权限校验。

隐藏域也不例外。只要数据从客户端回来,它就是普通请求数据,不能因为用户在页面上看不到输入框就提升其可信等级。

请求头、Cookie 和 IP 也属于客户端输入

User-AgentReferer、Cookie、X-Forwarded-For 都可以被构造。尤其是 X-Forwarded-For,业务代码常用它获取真实 IP,但它只有在一条受控代理链中才有意义。

正确的信任方式不是“看到 X-Forwarded-For 就相信第一个 IP”,而是:

  • 应用只接受来自可信反向代理或网关的流量;
  • 边缘代理清理客户端原始的转发头,再按照自身规则写入或追加;
  • 后端只信任已知代理链生成的转发信息;
  • IP 仍然更适合作为风控维度,而不是用户身份。

一个学校、公司、运营商 NAT 出口后可能有大量用户共享同一个 IP;同一个用户也可能频繁切换网络。因此,仅用 IP 判断“一个人只能领取一次”既可能误伤正常用户,也容易被绕过。

用户身份不能从业务参数里拿

如果一个面向浏览器或 App 的接口这样设计:

1
POST /coupon/claim?userId=10001

那么最大的风险不是参数有没有做数字校验,而是用户身份本身被设计成了客户端可决定的数据

当前用户应该来自认证上下文。使用 Spring Security 时,可以直接注入认证主体:

1
2
3
4
5
@PostMapping("/coupon/claim")
public ClaimResult claim(@AuthenticationPrincipal LoginUser loginUser,
@Valid @RequestBody ClaimRequest request) {
return couponService.claim(loginUser.userId(), request);
}

如果系统没有使用 Spring Security,也可以像传统 Spring MVC 项目一样通过 HandlerMethodArgumentResolver 统一从 Session、Token 解析结果或网关写入的可信上下文中组装当前用户,而不是让每个 Controller 自己接收 userId

这里还要区分“外部客户端”和“内部服务”。内部 RPC 确实可能显式传递用户 ID,但这不代表该参数天然可信。服务间仍然需要认证、授权和调用方约束,不能把“只在内网”当成安全模型。

重定向地址同样不能直接信任

登录后回跳通常会带 redirectUrl。如果服务端不校验就直接跳转,攻击者可以构造一个真实站点的登录链接,却把回跳地址指向钓鱼站点,形成开放重定向(Open Redirect)。

更稳妥的处理方式是:

  1. 优先只允许站内相对路径;
  2. 或者使用后端维护的目标编号,而不是客户端提交完整 URL;
  3. 必须跨域时使用严格的域名白名单,并规范化 URL 后再校验;
  4. 对非本站跳转增加明确的离站提示。

不要只用字符串 startsWith("https://example.com") 做域名判断,因为编码、子域、userinfo、双斜杠等 URL 细节很容易制造绕过条件。

涉及资源和资金时:防刷、限量、防重缺一不可

业务代码一旦涉及“有价值的东西”,安全要求会立刻提高。这里的“钱”不只是银行卡资金,还包括三类资源:

  • 按调用量收费的短信、邮件、OCR、地图、AI API 等第三方服务;
  • 优惠券、积分、红包、会员时长等可兑换价值的虚拟资产;
  • 扣款、退款、返现、提现、付款等真实资金流。

这三类场景分别对应三个关键词:防刷、限量、幂等。

防刷不是只做一个 IP 限流

以短信验证码为例,它通常在注册前就需要使用,因此天然是匿名开放接口。如果没有任何限制,攻击者拿到接口地址后就可以把平台的短信额度当成自己的资源使用。

真正可落地的防刷应该是分层的:

flowchart TD
    R[发送验证码请求] --> P{是否符合正常业务流程}
    P -- 否 --> X[拒绝]
    P -- 是 --> F{手机号/账号频率是否超限}
    F -- 是 --> X
    F -- 否 --> I{IP/设备/网络维度是否异常}
    I -- 正常 --> S[发送短信]
    I -- 异常 --> C[触发人机验证]
    C -->|通过| S
    C -->|失败| X
    S --> M[计量、监控、告警]

入口可以结合以下维度:

  • 同一手机号最短发送间隔;
  • 单手机号、单账号、单设备每日上限;
  • 单 IP、网段或设备指纹的异常速率;
  • 全局发送量和活动级预算;
  • 正常页面流程产生的短期一次性凭证;
  • 仅在风险升高时触发验证码或其他人机识别。

固定请求头只能作为很薄的一层门槛,因为请求头本身可以伪造。它能挡住一部分只抓 URL 的低成本脚本,但不能承担真正的认证或授权职责。

更关键的是,防刷必须和监控一起设计。只有限流没有告警,一旦阈值设计错误,系统可能在“不违反单个限制”的情况下持续产生高额费用。

虚拟资产必须先有预算,再有发放

优惠券、积分等资产最危险的设计,是“业务想发多少就现场生成多少”。如果资产可以立即抵扣真实消费,那么它本质上已经接近资金。

更合理的模型应该引入批次和总量:

1
活动申请 -> 审批/预算 -> 生成批次 -> 批次额度 -> 逐笔发放 -> 核销/过期/注销 -> 对账

在单机 Demo 中用 AtomicInteger 可以表达“剩余数量递减”的思想,但生产环境通常是多实例部署,不能依赖 JVM 内存计数。一个更稳妥的数据库原子扣减方式是:

1
2
3
4
UPDATE coupon_batch
SET remain_count = remain_count - 1
WHERE id = :batchId
AND remain_count > 0;

只有更新行数为 1 才代表成功抢到一份额度,然后在同一业务事务里创建具有唯一 ID 的优惠券记录。这样“数量”从一个程序变量变成了可审计、可恢复、可追踪的业务资产。

批次至少应该能回答这些问题:谁申请的、为什么申请、总额度多少、已发多少、剩多少、属于哪个活动、什么时候失效。安全不是只阻止攻击,也包括出了问题以后能够查清楚发生了什么。

资金防重的关键是业务订单号贯穿全链路

支付系统中最常见的错误之一,是每次重试都生成一个新的支付订单号。业务层认为“我重试的是同一笔订单”,支付通道看到的却是多个不同商户订单,于是每次都可能被当成一笔新支付。

幂等的核心不是“接口上加一个锁”,而是先确定一个稳定的业务幂等键,并让它从业务系统一直贯穿到最终资金通道。

sequenceDiagram
    participant B as Business Service
    participant P as Payment Service
    participant C as Payment Channel

    B->>B: 创建 bizOrderNo
    B->>P: pay(bizOrderNo, amount)
    P->>P: bizOrderNo 唯一约束/状态机
    P->>C: pay(bizOrderNo, amount)
    C-->>P: 相同商户订单号返回同一业务结果
    P-->>B: SUCCESS / FAIL / UNKNOWN

    Note over B,C: 重试时仍使用同一个 bizOrderNo

数据库层应当再用唯一约束兜底,而不是只依赖应用层先查再插:

1
2
CREATE UNIQUE INDEX uk_payment_biz_order
ON payment_order (biz_order_no);

资金订单还应该有明确状态机。例如:

1
2
3
INIT -> PROCESSING -> SUCCESS
\-> FAIL
\-> UNKNOWN -> RECONCILING -> SUCCESS / FAIL

UNKNOWN 很重要。调用第三方超时,并不能证明对方没有成功。如果此时简单把本地事务回滚,再用新订单号重试,就可能形成“对方已经扣款,本地却没有记录”的典型对账事故。

因此,更稳妥的思路是先落本地业务单,再调用外部系统。外部结果不确定时保留状态和请求标识,通过原业务订单号重试或对账确认,而不是把一次网络超时等同于业务失败。

监控和对账是最后一道止损线

防刷、防重属于事前控制,但任何控制都有可能被绕过或实现出错。涉及价值的业务至少应该监控:

  • 第三方 API 调用量和调用费用;
  • 优惠券、积分等资产的生产量、发放量、核销量;
  • 交易笔数、交易金额、退款/返现比例;
  • 单用户、单活动、单渠道的异常增长;
  • 本地记录与第三方账单之间的差异。

报警阈值不要只有一个固定值。把当前值与昨日同时段、上周同时段、活动预算和历史基线对比,更容易发现“绝对值不大但变化异常”的问题。

对于支付、短信等第三方交互,请求前后的关键流水、请求 ID、业务订单号和结果状态都应该可追踪。出现“我方记录少、对方记录多”时,往往意味着调用成功后本地未落库、超时重试、事务回滚或进程异常等边界问题。

数据必须永远只是数据:注入漏洞的共同根源

SQL 注入、脚本注入、模板注入虽然表现不同,但根因非常一致:把外部输入从“数据”升级成了“代码”。

一旦数据参与了 SQL 结构、表达式结构、脚本结构或 HTML 结构,攻击者就可能通过构造特殊输入改变原来的程序语义。

SQL 注入不只发生在登录接口

SQL 注入并不要求满足“GET 参数、接口返回数据、能看到错误信息”这些条件。POST Body、Cookie、Header 只要最终参与 SQL 拼接,都可能成为注入点。

即使接口没有任何返回内容,也可能通过盲注获取数据。常见方式包括:

  • 报错注入:让数据库错误信息带出目标数据;
  • 布尔盲注:通过真假条件造成不同响应;
  • 时间盲注:通过 SLEEP 等延迟构造真假差异;
  • 联合查询注入:利用 UNION 把攻击者希望查询的数据拼入原查询结果。

所以“统一隐藏异常信息”是必要的,但它不是 SQL 注入的根治手段。真正有效的边界是:SQL 结构由程序确定,外部值只能通过参数绑定进入。

JdbcTemplate 不要这样拼接:

1
String sql = "SELECT id, name FROM userdata WHERE name LIKE '%" + name + "%'";

而应该参数化:

1
2
String sql = "SELECT id, name FROM userdata WHERE name LIKE ?";
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, "%" + name + "%");

参数值再奇怪,也只会作为一个字符串值传给数据库,而不会改变 SQL 的语法结构。

MyBatis 中 #{}${} 完全不是一回事

MyBatis 里,#{} 会走参数绑定,${} 本质上是字符串替换。对于 LIKE 查询,应写成:

1
2
3
4
5
6
@Select("""
SELECT id, name
FROM userdata
WHERE name LIKE CONCAT('%', #{name}, '%')
""")
List<UserData> findByName(@Param("name") String name);

IN 查询不要让客户端直接传一个逗号拼接字符串再用 ${} 展开,而是传 List,使用 foreach

1
2
3
4
5
6
7
8
<select id="findByNames" resultType="com.example.UserData">
SELECT id, name
FROM userdata
WHERE name IN
<foreach collection="names" item="name" open="(" separator="," close=")">
#{name}
</foreach>
</select>

确实存在参数绑定无法直接覆盖的场景,比如动态列名、排序字段、表名,因为这些属于 SQL 结构而不是值。这时不能简单得出“只能用 ${},所以没办法防注入”的结论。正确做法是把外部输入映射到服务端白名单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public enum SortField {
ID("id"),
AGE("age");

private final String column;

SortField(String column) {
this.column = column;
}

public String column() {
return column;
}
}

客户端提交的是 IDAGE 这样的业务枚举,真正进入 SQL 结构的是服务端预定义列名,而不是任意字符串。

动态脚本和规则引擎也有同样的问题

如果业务使用表达式引擎、规则引擎或脚本引擎,下面这种模式和 SQL 拼接没有本质区别:

1
engine.eval("var name='" + name + "'; name == 'admin';");

攻击者只要构造能够闭合字符串的输入,就可能把后续内容变成可执行代码。正确方向是把变量通过 Binding、Context 或框架提供的参数机制传入,让脚本本身保持固定:

1
2
3
Bindings bindings = engine.createBindings();
bindings.put("name", name);
Object result = engine.eval("name == 'admin'", bindings);

不过这里必须加一个版本说明。早期 Java 项目常用 Nashorn JavaScript 引擎,并通过 SecurityManager + AccessControlContext 限制脚本权限。这套方案在现代 JDK 中已经不能照搬:Nashorn 已从 JDK 移除,Security Manager 也已经走完弃用并被永久禁用的路线。

今天更推荐的设计顺序是:

  1. 能不用通用脚本执行,就改成受限 DSL、规则模型或显式配置;
  2. 必须执行动态代码时,优先使用引擎自身提供的权限模型;
  3. 对真正不可信代码,使用独立进程、容器或其他 OS 级隔离,而不是指望 JVM 内部沙箱兜底。

这也是一个典型的版本演进:资料中的“限制脚本权限”这个安全目标仍然成立,但具体实现已经发生变化。

XSS:输入、存储和输出必须放在同一个上下文里理解

XSS(Cross-Site Scripting)的本质仍然是把数据变成代码。用户原本提交的是姓名、评论、简介,攻击者却提交了 HTML/JavaScript;如果页面在输出时把它当成可执行标记解释,脚本就会运行。

特别危险的是 Stored XSS:恶意内容先进入数据库,之后每个读取该数据的用户都可能触发脚本。

最重要的是按输出上下文编码

对于 Thymeleaf:

1
2
3
4
5
<!-- 安全的普通文本输出,会转义 -->
<span th:text="${username}"></span>

<!-- 会把内容当 HTML 解释,不能直接放不可信数据 -->
<span th:utext="${username}"></span>

如果业务真的需要“富文本评论”这类 HTML 输入,不能简单做字符串替换,而要使用 HTML Sanitizer 按允许的标签和属性做白名单清洗,然后在明确的富文本上下文中输出。

早期项目里常见一种做法:在 Servlet Filter、HttpServletRequestWrapper、Jackson 反序列化器中统一把所有字符串做 HTML Escape,然后把转义后的内容存进数据库。这种方式能够挡住一部分攻击,但不应该成为现代系统唯一的 XSS 策略。

原因是 HTML、JavaScript、URL、CSS 的安全编码规则并不相同。输入阶段统一把 < 变成 &lt;,会把“展示上下文”耦合进数据存储层,既可能污染原始数据,也无法保证它未来进入其他上下文时仍然安全。

更稳妥的原则是:

  • 输入阶段做业务合法性校验和必要的规范化;
  • 富文本场景做明确的 HTML 白名单清洗;
  • 输出阶段根据具体上下文做编码;
  • 历史库中已经存在的危险内容,也要在输出侧继续防守;
  • 模板默认使用自动转义能力,不轻易使用 raw/unsafe 输出。

HttpOnly 只能降低 XSS 的损失,不能消灭 XSS

Session Cookie 应开启 HttpOnly,这样 JavaScript 无法通过 document.cookie 直接读取它;同时通常还应配置 Secure,并根据业务选择适当的 SameSite 策略。

但需要理解它的边界:HttpOnly 只能阻止脚本直接读取 Cookie,不能阻止已经运行在页面里的恶意脚本以当前用户身份发起请求、读取页面内容或修改 DOM。因此它是纵深防御,而不是 XSS 的替代方案。

现代 Web 应用还可以通过 CSP(Content Security Policy)进一步压缩脚本注入的可利用空间,例如限制脚本来源、禁用不必要的内联脚本。安全设计越接近“即使某一层漏了,下一层仍能降低损失”,系统越不容易因为单点失误彻底失守。

敏感数据保护:先判断它到底需不需要“还原”

密码、身份证、手机号、姓名、密钥看起来都属于敏感数据,但不能使用同一种方式处理。选择算法前,先判断业务是否需要恢复原文。

数据类型 是否需要恢复原文 推荐方向
登录密码 不需要 专用密码哈希
身份证、手机号、姓名等隐私数据 可能需要 认证加密 + 密钥管理
精确检索辅助索引 不需要展示原文 HMAC/受控哈希索引
API Secret、加密主密钥 业务代码不应直接持有明文 KMS/HSM/Secret Manager
网络传输中的敏感数据 传输结束后不需要保持该密文 TLS

密码不是“加密保存”,而是做专用密码哈希

密码登录只需要验证“用户这次输入的密码是否和注册时相同”,并不需要恢复用户原始密码。因此数据库不应该保存可解密的密码密文。

MD5、SHA-1 这类通用高速哈希也不适合直接保存密码。问题不在于它们是否“可逆”,而在于它们太快,攻击者可以高速枚举候选密码。多做几次 MD5、加一个全站固定盐,也不会从根本上改变这个问题。

安全密码存储需要两个特征:

  • 每个密码使用独立随机盐,消除预计算和彩虹表优势;
  • 使用故意设计得较慢、可调成本的 Password Hashing 算法,提高离线爆破成本。

在 Spring Security 中可以使用 PasswordEncoder

1
2
3
4
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

注册时:

1
2
String encoded = passwordEncoder.encode(rawPassword);
user.setPassword(encoded);

登录时:

1
2
3
if (!passwordEncoder.matches(rawPassword, user.getPassword())) {
throw new BadCredentialsException("用户名或密码错误");
}

DelegatingPasswordEncoder 的价值不仅是避免自己处理盐,还能通过编码前缀支持算法迁移。BCrypt 仍然是成熟选择;新系统也可以评估 Argon2id 等内存硬型 Password Hashing 算法。具体成本参数不要照抄固定数字,而应该在生产硬件上基准测试,在可接受登录延迟和抗暴力破解能力之间取平衡。

即使密码哈希做得很好,在线暴力破解仍然存在,所以登录链路还需要失败次数控制、风险验证、账号保护和异常登录监控。

可逆隐私数据使用认证加密,而不是 ECB

身份证、手机号、姓名等数据有时需要恢复原文,因此必须使用可逆加密。AES 是常见对称加密标准,但“用了 AES”并不等于安全,工作模式同样重要。

ECB 会让相同明文块产生相同密文块,而且各分组独立,泄露结构模式,也缺少完整性保护。因此不应该用于敏感业务数据。

CBC 比 ECB 更好地隐藏重复模式,但它本身只提供机密性,不天然提供密文认证。今天处理业务敏感数据,更推荐使用 AEAD(Authenticated Encryption with Associated Data)模式,例如 AES-GCM

AES-GCM 同时提供:

  • 机密性:不知道密钥无法恢复明文;
  • 完整性:密文被篡改时认证校验失败;
  • AAD:可以把未加密但需要绑定的数据一起纳入认证。

Java 中的核心加密逻辑可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public record EncryptedValue(byte[] iv, byte[] cipherText) {}

public EncryptedValue encrypt(byte[] plaintext,
SecretKey key,
byte[] aad) throws GeneralSecurityException {
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);

Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.ENCRYPT_MODE,
key,
new GCMParameterSpec(128, iv)
);

if (aad != null) {
cipher.updateAAD(aad);
}

return new EncryptedValue(iv, cipher.doFinal(plaintext));
}

这里最容易踩的坑不是 API 调用,而是密钥和 IV 管理:

  • 不要把 AES Key 写死在代码或 Git 仓库里;
  • GCM 在同一密钥下不能复用 nonce/IV;
  • 密钥、密文、访问权限最好分离;
  • 业务服务不应随意读取长期主密钥。

独立加密服务、密钥与业务密文分开保存的设计方向仍然成立。现代生产系统通常进一步使用 KMS/HSM 做密钥托管和 Envelope Encryption(信封加密):业务使用 Data Key 加密数据,再由 KMS 的主密钥加密 Data Key,数据库保存的是密文、IV、认证 Tag、被包裹的 Data Key 和必要元数据。

flowchart LR
    B[Business Service] -->|请求 Data Key| K[KMS / HSM]
    K -->|Plain DEK + Encrypted DEK| B
    B -->|AES-GCM| C[Ciphertext]
    B --> DB[(Business DB)]
    DB --- C
    DB --- E[Encrypted DEK]
    DB --- I[IV / Tag / Key Version]
    B -. 不长期保存主密钥 .-> K

AAD 可以绑定 tenantIduserId、字段类型或业务记录 ID。这样即使有人把一条记录的密文复制到另一条记录上,解密时也可能因为 AAD 不匹配而失败。

加密后的手机号、身份证怎么查

随机 IV 的 AES-GCM 会让同一个明文每次产生不同密文,所以不能依赖“把查询值再加密一次然后等值匹配”来做一般检索。

如果只需要精确查询,可以单独保存一个检索索引:

1
2
3
phone_ciphertext = AES-GCM(phone)
phone_masked = 138****1234
phone_lookup = HMAC-SHA-256(indexKey, normalize(phone))

查询时对用户输入做相同规范化和 HMAC,再按 phone_lookup 精确匹配;真正展示时再按权限解密 phone_ciphertext

相比直接保存普通 SHA-256,HMAC 对手机号、身份证这类低熵数据更安全,因为攻击者即使拿到数据库,也不能在不知道独立 indexKey 的情况下轻易枚举所有可能值生成字典。

这种索引只适合精确匹配,不适合模糊搜索。对高敏感字段来说,“不能方便地 %LIKE%”通常是有意的安全取舍,而不是需要想办法绕过的限制。

日志、配置文件和第三方 Secret 也属于敏感数据面

数据库字段做了加密,并不代表敏感数据已经安全。如果请求日志、异常日志、审计日志里仍然打印身份证、手机号、密码、支付卡号或第三方 Secret,攻击者甚至不需要碰数据库。

日志脱敏最好在统一日志组件或结构化日志层完成,而不是依赖每个开发者手工 replace。例如把 mobileidCardbankCard 这类字段定义为敏感字段,在 Logback Converter、日志序列化器、网关日志插件或统一审计组件中做遮掩。密码、Token、Cookie、私钥和 SecretKey 原则上不应该进入业务日志。

第三方平台的 SecretId/SecretKey、数据库密码、TLS 私钥、KMS 凭据也不要提交到 Git 仓库或写死在 application.yml。生产系统应通过 Secret Manager、Kubernetes Secret 配合外部密钥管理、Vault、云厂商凭据服务或短期 IAM 凭证注入,并具备轮换和吊销能力。

这里要区分三个经常混在一起的词:脱敏用于展示,加密用于受控恢复,Secret Management 用于保护系统自身凭据。 三者解决的是不同问题,不能互相替代。

传输安全:理解 TLS,而不是在客户端自己再造一层“加密”

HTTP 明文传输无法保证机密性、完整性和对端身份。客户端自己把密码做一次 MD5、AES 或 Base64 再发给服务端,并不能替代 HTTPS:如果攻击者能够截获通信,同样可以重放客户端产生的那串“加密后密码”。

TLS 的目标是解决三件事:

  • 机密性:第三方不能读懂传输内容;
  • 完整性:数据被篡改可以被发现;
  • 身份认证:客户端能够确认自己连接的是目标服务端。

TLS 1.2 RSA 握手的历史模型

经典 TLS 1.2 RSA 握手可以抽象成:客户端拿到服务端证书中的 RSA 公钥,用它加密一个用于派生会话密钥的秘密;服务端用私钥解开;之后双方改用更高效的对称密钥加密业务数据。

它体现了典型的混合密码系统:非对称密码解决身份和密钥建立,对称密码承担大流量数据加密。

不过今天阅读这段流程时需要明确它的历史位置。现代 HTTPS 更推荐 TLS 1.3,静态 RSA 密钥交换已经退出主流握手流程,密钥建立使用 (EC)DHE 一类临时密钥交换,从而提供前向保密。

TLS 1.3 可以简化理解为:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: ClientHello + supported_versions + key_share
    S->>C: ServerHello + key_share
    Note over C,S: 双方通过 (EC)DHE 派生握手密钥
    S->>C: Certificate + CertificateVerify + Finished
    C->>C: 验证证书链、域名和签名
    C->>S: Finished
    Note over C,S: 派生应用流量密钥
    C->>S: Encrypted HTTP Request
    S-->>C: Encrypted HTTP Response

证书链的核心信任关系仍然没有改变:站点证书由中间 CA 签发,中间 CA 最终链到操作系统或浏览器信任的根 CA;客户端验证签名链、域名、有效期等信息后,才认为当前公钥确实属于目标站点。

对于服务间通信,如果双方都需要认证身份,可以使用 mTLS,让服务端也验证客户端证书。但 mTLS 的粒度主要是“这个客户端是否拥有合法证书”,它不自动等价于具体业务权限。因此,服务间仍然需要应用层授权、租户隔离、业务签名或请求权限控制。

把这些规则组合成一条完整的业务安全链路

前面的内容看起来分别属于输入校验、风控、支付、Web 安全和密码学,实际上它们可以落到同一条调用链上:

flowchart TD
    C[外部客户端] --> E[可信边缘代理 / Gateway]
    E --> T[认证与 Token 校验]
    T --> V[参数校验 / 业务白名单 / 授权]
    V --> R[防刷 / 配额 / 风险控制]
    R --> S[业务服务]

    S --> I[幂等键 / 状态机 / 唯一约束]
    I --> D[(业务数据库)]
    I --> X[第三方支付 / 短信 / 外部服务]

    S --> Q[参数化 SQL / 安全模板输出]
    S --> P[敏感数据加密 / 脱敏 / 查询索引]
    P --> K[KMS / HSM]

    D --> O[审计 / 指标 / 对账]
    X --> O
    O --> A[告警 / 熔断 / 人工止损]

这张图里没有哪一层可以单独宣称“系统已经安全”。安全更像木桶:最薄弱的那条路径足以绕过其他所有保护。

例如:

  • HTTPS 做得再好,服务端如果直接相信客户端传来的 userId,仍然会越权;
  • 参数校验做得再严,SQL 仍然用字符串拼接,依旧可能被注入;
  • 支付接口有数据库锁,但每次重试换一个商户订单号,仍然可能重复扣款;
  • 数据库用了 AES-GCM,但密钥和密文放在同一份配置备份里,分层保护就失去了意义;
  • XSS 已经修复输入入口,但历史脏数据输出时仍使用 raw HTML,旧数据依旧可能触发攻击。

真正稳定的做法是从数据生命周期思考:它从哪里来、谁有权决定、进入系统时如何校验、在内部如何流转、是否会被解释成代码、是否代表资产、如何持久化、如何传输、出了错怎么发现和恢复。

现代 Java 项目中需要特别修正的旧版本认知

如果从现代 Java/Spring 技术栈回看这些实现,会发现核心安全原则仍然成立,但有几处历史方案已经不能原样复制到新项目:

历史做法 今天应该如何理解
Nashorn JavaScript ScriptEngine Nashorn 已从 JDK 移除;动态执行应优先改为受限 DSL/规则模型,必要时使用独立引擎或进程隔离
SecurityManager 做脚本沙箱 Security Manager 已被弃用并在现代 JDK 中永久禁用,不能再作为新系统沙箱方案
Spring MVC 自定义 Session 注解获取用户 仍然可用,但 Spring Security 项目优先使用认证上下文与 @AuthenticationPrincipal
全局输入 HTML Escape 防 XSS 可作为局部补充,但现代实践更强调按输出上下文编码、富文本白名单 Sanitization 和 CSP
MD5 + Salt 保存密码 不应继续使用;选择 BCrypt、Argon2id 等专用 Password Hashing 算法
AES-CBC 作为主要敏感数据加密 更推荐 AES-GCM 等 AEAD 模式,同时配套 KMS/HSM 与密钥生命周期管理
TLS 1.2 RSA 握手作为 HTTPS 主模型 适合理解历史机制;新系统应优先启用 TLS 1.3,必要时兼容 TLS 1.2

这里反映出一个重要规律:安全目标通常比具体 API 活得更久。 “不把数据当代码”“不信任客户端身份”“限制动态代码权限”“密码使用慢哈希”“密钥与密文隔离”这些目标没有过时,过时的是某些实现它们的工具。

一份可以直接用于 Code Review 的检查清单

当一个新接口上线前,可以按下面的顺序快速检查:

检查面 需要回答的问题
信任边界 哪些字段由客户端决定?哪些必须服务端重算或查询?
身份 当前用户是否来自认证上下文,而不是请求参数?
权限 用户有权操作目标资源吗?是否只校验了“已登录”?
参数 是否有范围、格式、枚举、状态和业务白名单校验?
请求头 是否错误地把 IP、Referer、Cookie、自定义 Header 当成身份?
防刷 匿名资源是否有频率、单人、设备、全局和预算限制?
虚拟资产 是否先有批次、额度和审批,再有发放?
幂等 重试是否复用同一个业务幂等键?数据库是否有唯一约束?
外部调用 超时后如何判断结果?是否存在“第三方成功、本地回滚”的窗口?
SQL 外部输入是否全部参数化?动态结构是否严格白名单?
动态执行 是否把外部字符串拼进表达式、模板、命令或脚本?
XSS 输出是否按上下文编码?富文本是否经过 Sanitization?
Cookie Session Cookie 是否按场景配置 HttpOnly、Secure、SameSite?
密码 是否使用专用 Password Hashing,而不是 MD5/SHA 快速哈希?
隐私数据 是否使用 AEAD 加密?密钥是否脱离代码和业务数据库管理?
查询索引 低熵敏感字段是否避免直接普通哈希,改用受控 HMAC 索引?
传输 是否强制 HTTPS/TLS,内部敏感 RPC 是否也加密?
审计监控 调用量、资产量、交易金额、异常增长和对账差异是否可观测?
止损 一旦异常,能否快速限流、熔断、暂停活动或冻结资金操作?

总结

业务安全并不是在代码完成以后补几个过滤器,也不是接入一次漏洞扫描就结束。它首先是一种建模方式:客户端只表达意图,服务端掌握事实;用户身份来自可信认证链;所有外部输入都要在进入不同执行上下文前建立明确边界;有价值的资源必须有额度,资金操作必须有全链路幂等;密码与可逆隐私数据使用完全不同的保护方式;网络传输则交给成熟的 TLS 协议,而不是自己发明“加密参数”。

如果把整篇文章压缩成几句话,可以记住这几条:不信任客户端,不把数据当代码,不让资产凭空产生,不让同一笔钱执行两次,不保存原始密码,不把密钥和密文放在同一个篮子里,并且任何防线都要配套监控和对账。 这些原则看似基础,却恰恰是业务系统最容易因为“图方便”而被破坏的地方。


Java 业务安全设计:从不信任客户端到幂等、注入防护与敏感数据保护
https://allendericdalexander.github.io/2026/09/08/geeker/088java100error/3java-business-security-design/
作者
AtLuoFu
发布于
2026年9月8日
许可协议