必须用io.readall(r.body)一次性读原始字节,因github/stripe等平台验签依赖未修改的原始payload;多次读取会导致eof错误、签名失效;须先读取再验签和解析,且加长度限制防攻击。

Go 语言写 Webhook 接收端,最常翻车的不是“收不到”,而是“收得不安全、不稳、不快”——验签错一次,整个支付回调就可能被伪造;body 被读两次,json.Unmarshal 就直接报 EOF;handler 里同步调 DB,上游超时重试三次,订单状态就重复更新。
为什么必须用 io.ReadAll(r.Body) 一次性读原始字节
几乎所有主流平台(GitHub、Stripe、Slack、PayPal)的签名验证,都依赖未解析、未修改的原始请求体。而 json.NewDecoder(r.Body).Decode() 会内部消费 body 流,再读就空了。
- 常见错误现象:
hmac.Equal总是返回 false,但手动打印 header 签名和你算的值看起来“一样”——其实是 body 已被提前读走,你算的是空字符串的签名 - 正确顺序:先
payload, err := io.ReadAll(r.Body),再用payload做验签,最后用同一份payload做json.Unmarshal - 安全加固:加长度限制,比如
io.LimitReader(r.Body, 1024*1024),防恶意大包耗尽内存
hmac.Equal 验签时的三个硬性条件
验签不是“算出来一样就行”,它涉及编码格式、拼接规则、时序防护三重陷阱。
- GitHub 的
X-Hub-Signature-256是 hex 编码,别用base64.StdEncoding.EncodeToString去比;Stripe 的Stripe-Signature则是逗号分隔的多个字段(t=xxx,v1=xxx),需按文档提取v1段再比 - PayPal 和微信支付要求拼接 timestamp + payload,少一个换行或多了空格,签名就失效
- 必须用
hmac.Equal,绝对不要用==—— 否则暴露时序侧信道,可被用于签名爆破
HTTP handler 里只做三件事:验签、回 200、扔进队列
第三方 Webhook 服务(如 Shopify、GitLab、Alertmanager)普遍要求响应时间 ≤ 3–5 秒。任何数据库查询、外部 HTTP 调用、复杂 JSON 解析,都必须移出 handler。
- 响应必须严格:只调
w.WriteHeader(http.StatusOK),不写任何 body,不设额外 header(比如Content-Type) - 业务逻辑异步化:用
go func() { ... }()最简,但要注意 goroutine 泄漏;更稳妥的是用带 buffer 的 channel 或workerpool控制并发 - 幂等性不能靠 handler 补——要在异步任务里查
event_id或idempotency_key,避免上游重试导致重复扣款、重复发券
怎么让 http.Server 不因一个慢请求拖垮全站
默认 http.Server 没有读写超时,一个卡死的 handler 会持续占用 goroutine,连接数涨到上限后新请求直接拒绝。
-
ReadTimeout控制从 TCP 握手完成到请求头读完的时间,设为5 * time.Second即可覆盖绝大多数场景 -
WriteTimeout更关键:它必须 ≤ 上游 Webhook 平台的超时阈值(GitHub 是 10s,Stripe 是 5s,Slack 是 3s),建议统一设为3 * time.Second - 别忽略
IdleTimeout:防止长连接空转占资源,设为30 * time.Second是合理下限
真正难的从来不是“把 JSON 解出来”,而是当 Stripe 和 PayPal 同时在凌晨三点发来重试请求,你的服务还能准确验签、不丢事件、不重复处理——这些细节藏在 io.ReadAll 的时机、hmac.Equal 的用法、以及 http.Server 那几个 timeout 参数的组合里。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











