
最近折腾Webhook对接,踩了几个坑才明白:大多数安全问题都不是什么高深莫测的漏洞,就是那么几个老错误反复出现。团队验了一次签名,看到通过了,就直接上线了。这篇把上线前必须做的检查捋一遍,每条都是实际有人踩过的坑。
这是所有Webhook提供商最常见的bug。签名是提供商发送的原始字节计算出来的HMAC(哈希消息认证码)。如果你的框架先解析了JSON,再从解析后的对象重建字符串来验证,字节早就对不上了。键的顺序可能改变、空格被压缩、数字格式调整,正确的HMAC永远匹配不上。
先捕获原始的、未解析的请求体,验证通过后再解析。不同框架做法不同:Express(Node.js Web框架)需要用express.raw(),Next.js(React服务端渲染框架)路由处理器用await req.text(),Cloudflare Workers(边缘计算服务)用await request.text()。支付宝、微信支付、百度智能云等国内服务商都适用这个原则。
算出预期签名后,不要用===或==来比较。普通字符串比较遇到第一个不同字符就返回,微小时序差异会泄露前面有多少字节是对的。给攻击者足够多尝试,就能逐字节伪造出有效签名。
用恒定时间比较:Node.js(JavaScript运行时)用crypto.timingSafeEqual,Python用hmac.compare_digest,PHP用hash_equals。无论第一个还是最后一个字节不同,耗时都一样。使用前确保两个缓冲区长度相同,因为timingSafeEqual在长度不匹配时会抛异常。
有效签名只能证明请求来自提供商,不能证明请求是新鲜的。如果攻击者截获了一个已签名请求,只要你没检查时间戳,他们可以无限重放。
大多数提供商把时间戳纳入签名字符串,就是为了让你能拒绝过期请求。支付宝、微信支付都签名时间戳,建议拒绝超过五分钟的请求。读取时间戳,确认在可接受范围内,超时就拒绝。这也是为什么签名要包含时间戳加请求体,而不是请求体单独签名。
验证失败时返回401或403,直接丢弃请求。不要"姑且"处理。更不要因为签名检查"在测试环境有点问题"就降级处理未签名请求。接受未签名请求的Webhook端点,就像门上装了个锁,但锁是装在门旁边的墙上。
日志要足够调试真实故障,但不能泄露密钥:记录到达的header、时间戳,以及失败原因是签名错误、时间戳过期还是header缺失。永远不要记录签名密钥或计算的HMAC。当提供商轮换密钥或代理破坏了header时,这些日志决定了你需要五分钟还是一下午来修。
提供商会重试。超时的投递,或者你的服务返回500,会被再次发送,有时候提供商成功了也会重复发送同一个事件。如果你的处理器扣款、发通知、递增计数器,但没有检查是否已经处理过那个事件,重试就变成了重复的副作用。
每个提供商都提供稳定的事件ID。记录已处理的事件ID,让相同ID的第二次投递变成空操作。这是重试成为安全网而不是第二笔扣款的关键。
Webhook端点必须通过HTTPS提供服务,这样签名请求在传输过程中才不会被读取或修改。然后把已验证的载荷当作仍需验证的输入。签名证明提供商发了,但不能证明事件类型是你处理的、所需字段都存在、金额在合理范围内。在 Acting on them之前,检查事件类型是否在允许列表中并验证读取的字段。
上面每一项都在加固到达的请求,但对永远不会到达的请求毫无帮助。部署期间触发的Webhook、流量高峰时超时的突发请求、处理器在确认前抛异常被丢弃的事件。提供商的重试行为不均衡:支付宝、微信支付会重试数天,有些平台重试几次就禁用你的端点,还有些几乎不重试。与金钱和状态相关的事件恰恰是绝对不能静默丢失的,而签名检查对从未发生的投递毫无作用。
那层保障是 EventDock 在检查清单之下添加的。你把任何提供商指向 EventDock 而不是你的应用。EventDock 用原始请求体验证签名,存储事件,立即向提供商确认,这样超时窗口就不重要了,然后用自带的重试机制和死信队列投递到你的应用。如果你的应用宕机一小时,事件会等待并在恢复后到达,而不是消失。签名验证和投递可靠性是同一个问题的两面,Webhook的安全性取决于它的可靠性。
你可以在免费版本上接入任何提供商,查看每个事件如何被验证、存储和投递。