一条接口通路上依次排着三道闸门,第一道对着表盘,第二道翻检登记簿,第三道核对封条上的字段清单

一、三件套防的是三种不同的重来

防重放在评审会上经常被压成一句话:“不是已经做签名校验了吗。”这一句把三件事糊成一件,而时间戳窗口、随机数去重与签名原文校验的职责边界并不重叠,它们不是同一件事的三种写法。

时间戳窗口防的是很久以前的凭证被拿到今天用。它只做一件事,把有效期缩短。它并不阻止有效期内的任意次重来:同一个请求在窗口之内被投递多次,窗口这一层每一次都会放行。所以时间戳是窗口语义,它能容忍一定的时钟偏移,代价就是窗口内部不设防。

随机数去重防的恰好是窗口内那一段重复投递,也就是时间戳管不住的部分。它必须依赖一份有保留期的记录,记录的内容是某个随机数见过还是没见过。这里有个常被跳过的前提:这份记录是跨实例共享的有状态资产,一旦只放在单个进程里,多副本部署时它就近似于不存在。

签名防的是内容被改,它本身不防重来。只有当时间戳与随机数都被绑进签名原文,签名才顺带把防重放字段锁住;拼串清单一旦漏掉这两个字段,替换它们并不会让签名失效。把签名当成防重放的主力,是这一层最典型的误用。

三件事写成一张表,比并列三段话更容易在评审时被逐条问到底。

机制 防的是哪一种重来 要保存什么状态 保留期与窗口怎么配对 该组件故障时的默认行为
时间戳窗口 很久以前的凭证被拿到今天用 不保存业务状态,只依赖服务端可信时间与一个写明的窗口长度 窗口长度决定去重保留期必须超过的下限 判不出来就直接拒绝,并回一个能说清是时钟偏移的错
随机数去重 窗口内的重复投递 一份跨实例共享、带保留期的“是否见过”记录 保留期要明显大于窗口,两者一起进设计文档 放行还是拒绝必须预先写死,不留给运维临场决定
签名原文校验 内容被改,含防重放字段被替换 一份版本化的参与拼串字段清单 与窗口无配对关系,但清单必须包含前两者的字段 拒绝,且在任何顺序里都排在最前

由此就有“三样都做了、实际只有一样在起作用”的两种典型形态。其一,签名原文不含防重放字段,此时签名只保证内容不改,重来全指望窗口,而窗口本来就不管这一种。其二,去重状态各实例自留,此时去重只在同一副本上成立。判这两件事不需要跑任何试验,只要读两张清单:参与拼串的字段清单,去重记录的真实存放位置。

二、窗口与保留期是一条可验收的配对约束

时间戳与随机数不可互相替代,这一点讲完还要补一条硬约束:去重记录的保留时长必须明显大于时间戳窗口。理由是一个区间问题。一个请求带着贴近窗口边缘的时间戳迟到,服务端要先让它在窗口校验上通过,再拿它的随机数去查记录。如果保留期只和窗口一样长,那么在窗口边缘附近的那一小段时间里,请求仍然能通过时间戳校验,而它的去重记录已经过期被清走,两层校验合起来等于没防。这个缺口不出在配置错误的那一侧,而出在两行配置各自看着都合理的中间地带。

典型配错有两种。第一种是保留期等于窗口,或者按“都设成同一个值,省得想”来配,边界情况一多就撞上上面那个区间。第二种是保留期无限,或者干脆按“留着总更安全”来做只增不删的登记簿,存储被填满只是时间问题,填满之后要么拒绝一切、要么走回放行,两种结果都不是设计想要的。评审时把这一条当成一句话就能问完的题目:窗口多长,保留期多长,谁大于谁,清理动作由谁触发。

三、六类失效场景,各给一条确认判据

失效场景的价值在于它能反推该配哪一层。下面六类的说法,前半是它看起来像什么故障,后半是拿什么判据确认。

  1. 窗口放得过宽。看起来像接口偶发重复提交、下游有脏数据,但错误率很低,没人当回事。判据是把窗口长度与该接口的重试上限、单次业务动作的耗时放在一起比:窗口只要明显长于业务本身的重试周期,这一层实际就把防护换成了限速。
  2. 去重状态放本地而服务多副本。表现为重来间歇性成功,而且成败与落到哪个实例有关,压测时复现不稳定。判据是按实例维度统计重复投递的接受率,如果接受点集中在轮转到不同副本之后,问题就在存储位置而不在算法。
  3. 签名原文漏掉防重放字段。表现是内容一改就报错、字段照抄却能反复提交,看上去像签名很灵。判据是逐字段比对参与拼串的清单与协议里声明的防重放字段,缺项即失效,这时剩下的攻击面就只是这两处字段本身。
  4. 客户端时钟偏移造成大面积误拒。看起来像上线事故,容易被当成容量问题处理。判据很干脆:时间戳相关的错误率与设备型号、系统版本、终端品牌分布相关,而不是与流量规模相关。与流量相关的是过载,与设备分布相关的是时钟。
  5. 去重存储故障时没有明确策略。表现是存储抖动那几分钟里,要么大面积拒绝,要么全部放行,而两种行为都能被解释成“降级”。放行还是拒绝这件事必须写进设计,写成一行的默认值与触发条件,不能留给运维在告警声里临场判断。
  6. 重试链路和跨机房把同一请求当两次不同请求。表现为幂等与防重放同时失效,同一笔业务动作留下两条记录。判据是看去重键在链路上被谁生成、生成几次:如果客户端重试与网关重试各自生成,或者同一次请求跨机房时键的来源不同,那么这两层就互相抵消。

四、集中式原子去重的实现要点

去重这件事的实现要求只有一条:判定是否见过与写入这条记录,必须是同一步原子操作。先查后写在并发下会双双通过,两个并发请求都读到没见过,于是都放行,这恰好是攻击与重试最集中的时刻。用唯一约束的一次插入比读写两趟更稳,原因很朴素:并发冲突交给存储层的一次约束检查,冲突方拿到的是明确的重复结果,不需要自己判断竞争。

去重键的粒度是第二个设计项,它同时改变防护强度与存储压力。只按接口建键,等于允许同一个调用方在不同业务动作上互相顶掉记录;按接口加调用方建键,强度提高,但一个调用方一个键的登记量会随接入方数量线性上去;按业务动作建键最贴合语义,也最依赖调用方诚实而唯一地给出动作标识,这一点要写进接口约定,不能默认。粒度选择要连着保留期一起定,因为登记量上限就是粒度乘以保留期内的时长。

五、校验顺序本身是契约

三类校验的先后次序是设计项,顺序错了会放行,也会把成本转嫁给自己的存储。推荐顺序是:先验签名完整性,再验时间戳窗口,最后做随机数去重。

这样排的理由是成本递进。签名校验是纯计算,不写任何东西;窗口校验只读一个服务端时间与请求里的一个字段;去重要做一次写入,是唯一会消耗共享存储的一步。如果把去重放在前面,任何一把伪造签名都能免费在你的登记簿里添一条记录,无效请求就能把你的内存或存储写满,而写满本身就是一种拒绝服务。评审时这一条只要问一句:走到写那一步之前,需要花掉对方多少成本。答案是零,顺序就得改。

顺序要当成契约来维护,具体是三个动作:设计文档里把顺序写成编号条目而不是画一张流程图;代码评审按条目核对,任何新增校验都要声明插在哪两步之间;埋点记录每一层的拒绝比例,比例分布一旦反过来说明顺序被改动了。

六、与相邻层分开,再把五件事写进接口文档

分层比实现更容易出错的地方在于串线。浏览器会话侧的跨站请求防护是另一条线,它处理的是会话内自动携带凭证的问题,服务端开放接口不会因为它配好就免掉防重放;把两套机制混在同一层实现里,最常见的后果是接口侧凭空多出一段没人能解释的校验。网关做的身份认证也不等于防重放:认证回答的是“谁在调”,重来这件事回答的是“同一次意图被算了几次”,认证通过两次不构成任何一层的拒绝理由。还有一条要落到人名上:客户端自己重试与网关重试的归属,由谁负责去重要在设计里写清,否则两层都会以为对方在处理。

对外开放接口给客户系统调用时,有五件事必须写在接口文档里,而不是留给客户自己猜。写清这五条,是洹拓科技这几年做集成类交付换来的规矩:

  1. 窗口长度,以及对调用方时钟同步的明确要求,包括偏移超出多少会被拒绝。
  2. 随机数的唯一性范围与长度要求,说明它按调用方唯一还是按接口唯一。
  3. 参与签名的字段清单,逐字列出,并注明新增字段时的版本变更方式。
  4. 重复投递返回什么错误码,这个码要与参数错误明确区分开,好让调用方的重试策略能判断出继续重试没有意义。
  5. 时钟偏移超限时的排查指引,告诉调用方先查哪一段,而不是只把错误码甩回去。

这五条里前四条是约束,最后一条是态度。接口文档里缺任何一条,客户系统就只能靠试错来猜你的窗口和保留期怎么配,猜出来的实现最后一定会变成线上那段没人负责的重复数据。