不是我们发起的调用,而是我们接收的推送
一句话总结
Webhook 是控制权在对方手里的集成,所以接收的一方必须自己判断四件事:是谁发的(签名)、是什么时候发的(时间窗口)、是不是已经见过(重复)、是不是更新的(顺序),而且必须在完成这些判断之前就返回 200。
为什么需要它
在我们调用 API 的集成中,起始时间和重试策略都由我们来定。Webhook 则相反。对方向我们的地址 POST,而我们既不能拒绝、也不能推迟这个请求。这里有三件事同时来咬我们。
第一,任何人都可以向我们的地址 POST。Webhook 接收端点必须对互联网开放,对方才能调用。这就意味着别人也可以调用。没有人知道这个地址,并不是一种防御。
第二,对方会重新发送。如果我们迟迟不返回 200 或者没能返回,对方就会视为失败并重发。也就是说,我们的处理时间越长,重复就越多。而且重发并不只在我们处理失败时才会出现。我们已经处理完了、只是响应晚了,导致对方没有收到的情况要多得多。
第三,顺序得不到保证。对一笔订单发生了 accepted → paid → shipped 三个事件,并不代表它们会按这个顺序到达。对方并行发送,或者一条在重试的过程中下一条先到了,顺序就会颠倒。如果按到达顺序去覆盖,订单状态就会倒退。
工作原理
签名。常用的方式,是把用共享密钥计算出的 HMAC 放在请求头里发送。RFC 2104 定义了 HMAC,实际工作中的实现大多遵循 Stripe 的 Webhook 文档和 GitHub 的投递验证文档所展示的形式。请求头中包含时间戳 t 和签名 v1,而签名的原料是把时间戳与原始正文连接起来的字符串。
这里最常见的错误,是把正文解析之后再重新序列化,再来计算签名。键的顺序或空白哪怕差一个字符,签名也会变成完全不同的值。签名校验必须按收到的字节原样进行。
第二个错误是比较方法。如果用 got == want 来比较,在第一个不同的字节处就会立刻结束,而这个时间差会给攻击者提供“前面几个字符是对的”这一信息。在 Python 中,hmac.compare_digest 会以与长度成正比的时间进行比较。
时间窗口。签名正确,并不意味着这是现在发的。把以前传过的有效请求原样再塞进来,称为重放(replay),光靠签名是挡不住的。所以要把时间戳放进签名原料,由接收的一方查看它与当前时刻之差是否在窗口(通常是几分钟)之内。窗口设窄,时钟只要稍有偏差,正常请求就会被拒绝;设宽,则可重放的区间会变长。
重复。陷阱在于有两个键。投递编号(delivery id)指的是一次传输,事件编号(event id)指的是发生的一个事件。用同一个投递编号再次到来,那是重发;而投递编号是新的、事件编号却相同,那也是同一个事件。需要阻止的是事件被重复应用,所以真正的键是事件编号。只用投递编号过滤,就会漏掉后一种情况。
顺序。不要相信到达顺序,而要根据事件自带的版本(version)或发生时刻来判断。只有比当前保存的更新时才应用,旧的就悄悄丢弃。
POST /webhook
│
├─ 서명 틀림 ──────────▶ 400 (원장에 손대지 않는다)
├─ 시각 창 밖 ─────────▶ 400
└─ 통과 ─▶ 큐에 적재 ─▶ 200 (여기서 끝. 처리는 뒤에서)
│
└─ 배수(drain) ─▶ 중복인가? 더 새것인가? ─▶ 원장 적용
尽快返回 200 是最后一块拼图。如果在接收的位置连账本都去动,处理越慢就越容易触发对方的超时,于是重发增多,重发增多又使处理更慢。只做校验、放入队列后立刻应答,就能切断这个循环。
在现场相遇的样子
第一,“偶尔订单状态会倒退”是最常见的投诉。原因几乎总是按到达顺序做了覆盖。看日志,会发现在 shipped 之后应用了 paid。
第二,签名校验在框架中悄悄失效。在会自动解析正文的框架中,必须另外找到获取原始字节的方法。也有代理会改写正文的情况(解压、字符集转换)。
第二点五,计划里没有包含密钥轮换。一旦更换密钥,在此之前发出的投递会全部被拒绝。所以在轮换期间,要同时接受旧密钥和新密钥,两者有一个匹配就放行。
第三,队列积压时没有事先定好丢弃什么。Webhook 会持续进来,所以队列会无限增长。如果每个事件都有版本,同一对象的旧事件是可以丢弃的,但这个判断必须事先定好。
第四,即使文档上写着对方会保证顺序,也不要相信。对方只要重试一次,顺序就会打乱。接收的一方如果有版本比较,就没有损失;如果没有,就会出事故。
下一项实验要做什么
启动一个推送订单事件的合作方发送器,拿到一天的 41 条投递。其中混有重发、同一事件的新投递、陈旧的重放尝试,以及不知道密钥的一方伪造的假投递。制作签名校验器并以常数时间比较,加上时间窗口,用投递编号和事件编号这两个键过滤重复,并让它只在版本更新时才应用。然后制作能快速返回 200 并放入队列的接收端点,最后把一天的数据毫无遗漏地重新放一遍,统计每个分支各有多少条。