Webhook 防重放需补齐时间窗与幂等
文章指出,仅验证签名无法阻止合法请求被重复提交,并给出签名校验、时间戳校验和请求去重的顺序管线。示例采用 HMAC-SHA256、前后 300 秒时间窗及基于 request_id 的重放检测。
AI 深度解读
验过签的 Webhook,仍可能被重复执行。攻击者只要拿到一份合法请求及其签名,就可能原样重发,让同一笔业务再次修改状态。原文提出的防线是:先验签,再限制时间偏差,最后检查请求是否已经处理。
这套顺序容易理解,也适合作为服务端接收 Webhook 的入门框架。但示例离生产可用还有距离:内存缓存无法覆盖多实例,并发检查存在空隙,自写缓存还有递归缺陷。值得学的是三层校验各自负责什么,以及为什么「通过校验」还不等于「业务只执行一次」。
签名正确,为什么还会出问题
数字签名校验回答的是:这份请求是否符合约定密钥和签名算法,内容有没有被篡改。它没有回答另一个问题:这份请求以前是否已经执行过。
原文使用 HMAC-SHA256,根据密钥和原始请求体计算摘要,再用 hmac.compare_digest 比较签名。攻击者即使不知道密钥,只要能获取合法请求与对应签名,原样重发时仍能通过检查。
所以作者把接收流程拆成三关:
- 校验签名,失败对应
401 Unauthorized。 - 校验数据结构和时间戳,允许偏差为
±300s,超出范围在流程图中对应400 Bad Request。 - 检查
request_id是否存在,重复请求对应409 Conflict,首次出现则登记并返回成功。
这里的时间限制也不是完整答案。它能挡住过旧的请求,却挡不住有效窗口内的重复提交。请求刚发出几秒就被重放,仍然需要第三关拦截。
验签确认请求可信,时间窗口限制请求寿命,去重记录限制重复执行。
三者互相补位。还有一个隐含前提:时间戳和请求标识必须受到签名保护。示例把它们放在整个请求体里一起签名,攻击者不能随意改成新时间、新 ID,再沿用旧签名。
代码值得保留的是顺序,参数需要结合业务
示例用 Pydantic 定义 WebhookPayload:request_id 是非空字符串,timestamp 是整数,data 是字典。下面这段负责拒绝与服务器时间相差超过 300 秒的请求:
@field_validator('timestamp')
@classmethod
def validate_timestamp(cls, v):
# 允许的时间偏差为 5 分钟(300 秒)
if abs(time.time() - v) > 300:
raise ValueError("Timestamp drift too large")
return v
关键是 abs(time.time() - v) > 300:过旧和过于超前的时间都会被拒绝。发送方与接收方时钟不准、请求排队太久,也可能触发它。300 秒是示例选择,原文没有提供这个参数适合哪些业务的测量依据。
完整处理方法把三关串了起来:
def process_webhook(self, raw_body: bytes, signature: str):
# 1. 签名验证
if not self.verify_signature(raw_body, signature):
raise UnauthorizedError("401 Unauthorized: Invalid Signature")
# 2. 数据结构验证
data = json_loads(raw_body)
payload = WebhookPayload(**data)
# 3. 防止重放攻击(幂等检查)
if payload.request_id in processed_requests:
raise ConflictError("409 Conflict: Replay detected")
processed_requests[payload.request_id] = True
return {"status": "success"}
要看清第一行验签使用的是 raw_body。如果接收端先解析 JSON,再重新序列化后验签,空格、字段顺序等字节变化可能让签名失配。接到 Node 服务时,也应按发送平台的签名协议保留所需的原始请求体。
去重缓存配置为 TTLCache(maxsize=10000, ttl=600),最多保存 10000 条记录,保留 600 秒,也就是 10 分钟。时间窗口允许请求最多提前 300 秒,因此一份首次被接收的请求,理论上还可能在随后约 600 秒内满足时间条件。这个组合有逻辑联系,但原文没有解释容量上限如何根据流量确定。
还要区分异常与 HTTP 响应。流程图列出了 200、400、401、409,代码却没有 Web 框架路由或异常处理器。自定义异常中的状态码文字不会自动变成 HTTP 状态码;Pydantic 校验失败如何映射响应,也需要接入层处理。
内存去重能演示流程,却撑不起严格幂等
原文明确承认:单进程内存缓存无法在多服务器之间共享。一次请求落到实例 A,重放请求落到实例 B,B 的缓存为空,就可能再次放行。AWS Lambda 这类部署同样存在实例状态隔离问题。作者因此提出,分布式生产环境需要 Redis 等共享持久层。
不过,只把字典换成 Redis 仍不够。示例中的「检查是否存在」与「写入记录」是两个动作。并发请求可能同时检查到不存在,然后分别继续执行。共享存储解决的是大家能否看到同一份记录,还需要原子操作解决谁能取得处理资格。
更大的空白在业务执行部分。代码登记 ID 后直接返回 {"status": "success"},没有展示任何实际状态修改。如果登记成功后业务失败,后续重试可能被拒绝;如果先执行业务再登记,中途崩溃又可能留下重复执行的机会。
这段代码演示的是接收端去重检查,还没有实现完整的业务幂等。
缓存容量也属于安全边界。达到 10000 条后,被提前淘汰的记录可能仍处于请求有效期内。原文未给容量压力测试,不能据此保证整个时间窗口都受到保护。
作者还为 cachetools 和 orjson 提供了标准库回退:分别使用 dict、time 和 json。这样可以减少部分依赖缺失导致的 ModuleNotFoundError,但 Pydantic 仍然直接导入,因此不能理解成完全不依赖第三方包。
自写 TTLCache 还有明确缺陷:访问过期键时,__getitem__ 调用 _remove;_remove 又用 if key in self 检查,重新进入 __contains__ 和 __getitem__,形成递归。过期清理可能因此失败。原文提到了自写缓存的维护成本,却没有识别这个具体问题。
我的判断
作者对威胁的判断是对的:只验签不足以防重放,接收端必须考虑请求的新鲜度与重复处理。把检查顺序明确写出来,也有助于团队评审接入代码。
但文章没有拿出足够的验证证据。所谓测试部分只展示了 uuid-123、{"val": 1} 和 super-secret-key 组成的正常路径,没有展示重复提交、时间超限、过期清理或并发请求的断言与结果。正文的三项工程考虑还各自重复了一遍。它更适合作为方案草稿,不能当成已验证的生产实现。
与维护自写回退缓存相比,普通团队通常更适合先把依赖安装、版本管理和构建检查做好;与单纯记录请求 ID 相比,有真实业务写入的系统还需要考虑数据库唯一约束、事务或处理状态。具体采用哪一种,取决于业务更新发生在哪里,原文没有给出比较数据。
文末另有 AI 协议宣传,宣称节省约 90.1% 的能耗和 token 开销,并列出 23.4x 等数字,却没有提供可核对的测量过程,也没有建立这些指标与 Webhook 防护效果的关系。这部分不能充当架构正确性的证据。
能拿来做什么
- 给现有 Node Webhook 入口做一次流程检查,确认验签使用协议要求的原始字节,并且时间戳、请求标识都受到签名保护。
- 把重复请求、超过
±300s的请求、缓存过期和并发到达加入验证场景,检查最终业务状态是否只更新一次。 - 准备从单实例扩到多实例时,把去重状态迁到共享存储,并验证检查与登记能够原子完成,同时明确业务失败后的重试方式。
- 让 AI 生成接入代码时,要求它同时交代异常到 HTTP 状态码的映射、依赖安装方式,以及登记请求后业务执行失败如何恢复,再据此审查代码。