一、地址暴露面的收敛
路径带业务号(如 /callback/pay/{order_no})是最常见的泄露源:任何能下单的调用方都能拿到格式正确的样本,遍历即可试出他人的地址。设计原则:回调地址统一入口、不含业务标识;前端不出现回调 URL;错误页不输出配置项;对合作方的地址泄露视为既成事实。
二、伪造请求的三种形态
| 形态 | 特征 | 现有验签是否挡得住 |
| 裸奔 | 无签名头 | 挡得住 |
| 签名复用 | 合法签名 + 篡改 body | 取决于是否基于原始字节 |
| 旧报文重放 | 报文完全真实,仅重复投递 | 取决于时间戳窗口 |
第三种形态最值得警惕:签名算法正确、报文真实,仅缺少投递时效约束。时间戳窗口按小时配置时,旧通知可重放至业务层。
三、四层校验与实现
层一 签名 + 时间戳 → 来源与新鲜度(≤5 分钟窗口) 层二 字段一致性 → 金额 / 币种 / 收款方与本地订单比对 层三 状态机方向 → 仅接受合法迁移,回调不直接写状态字段 层四 幂等落库 → 唯一索引兜底,不依赖先查后插
MAX_SKEW = 300 def verify(raw_body: bytes, headers: dict) -> bool: ts = headers.get("x-cb-timestamp", "") sig = headers.get("x-cb-signature", "") if abs(time.time() - int(ts)) > MAX_SKEW: return False base = ts.encode() + b"." + raw_body for secret in SECRETS: # 新旧密钥并存 if hmac.compare_digest( hmac.new(secret.encode(), base, hashlib.sha256).hexdigest(), sig): return True return False
修正一:签名的输入必须是原始请求体字节,在框架解析之前读取;解析后重排的字段无法复现签名。修正二:比较使用定长比较函数,不用 ==。修正三:密钥更换期间新旧并存,避免切换窗口内回调全部失败。
四、响应口径与验收标准
响应口径:除验签失败外,其余分支(订单不存在、金额不符、状态不合法、重复通知)一律返回成功;业务失败进入补偿队列,不依赖上游重试。「订单不存在」不得返回独立响应体,避免响应差异成为地址探测器。
验收标准一:用录制的真实回调报文对接口重放 100 次,业务数据零变化。验收标准二:篡改金额后重放,告警触发且订单状态不变。验收标准三:密钥更换窗口内,新旧密钥的通知均验签通过。