登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go问答

Go Webhook 验签后 JSON 解析为空:把 r.Body 的读取边界收回到一处

来源:17golang原创

时间:2026-07-19 15:08:19 493浏览 收藏

对接Webhook回调的过程里,不少人都遇过这类反常情况:签名校验明明完全通过,后续走json.Decoder解析出来的结构体所有字段全是空值EOF。问题和JSON格式本身没有关系,根源是r.Body已经被前面的验签逻辑读完,游标直接移到了流的末尾。Webhook收到的原始字节既要用来计算HMAC校验签名,还要用来解析业务字段,这两步操作必须走统一管控的同一条数据链路,不能各自独立读取请求内容。

不要零散拆分多个逻辑分别读取请求体,把原始请求体的读取入口收拢到同一处,先校验完合法性再把完全相同的字节内容交给后续解析流程,就能彻底避开这类读到空内容的问题。
实践要点
  • 把原始请求体只读取一次,同时给读取长度设置符合业务场景的上限,避免超大请求占用过多服务资源。
  • HMAC必须基于完全没有经过任何修改的原始字节计算,不能先反序列化再重新拼接JSON拿去算签名。
  • 签名比对直接用hmac.Equal,不要用普通字符串相等判断,防止时序差泄露问题被外部攻击者利用。
  • 验签、事件类型校验和幂等落库每一步都留好操作记录,任意一步校验不通过就直接跳过后续所有业务分支。

为什么验签通过后,业务代码读到的直接是EOF

http.Request.Body是流式读取对象,不是可以随意来回跳转重复扫描的字节数组。如果中间件里用io.ReadAll读完请求体算完签名,没有把读到的字节缓存下来传递给后续的业务处理器,后面的JSON解码器自然只能读到流的末尾,拿到空内容。

不要一上来就给各个中间件乱塞重置请求体的补丁,Webhook场景下逻辑拆得太散很容易出现边界问题:日志层读一次、验签层读一次、业务层再读第三次,到最后根本说不清哪份字节是经过验签的,哪份字节是直接拿来解析的,排查问题全靠猜测。

阶段应保留的记录禁止执行的操作
接收请求长度、请求ID、事件头信息直接把完整请求体明文写入普通日志
验签原始字节和签名头的匹配结果先格式化调整JSON内容再拿去计算HMAC
解析事件类型、第三方侧传递的事件ID验签未通过就直接反序列化写入数据库

把验签和JSON解析接入同一条数据链路

回调请求体积普遍偏小的场景下,最稳妥的写法是直接在同一个函数里先读取限制了长度的请求体,校验完成签名之后,再把完全相同的一份字节传递给JSON解析器。长度上限不要直接写死通用常量,要对应接入平台的官方说明和自身业务事件的最大体积来设置,下文写的1MiB仅作演示参考值。

func readAndCheck(r *http.Request, secret []byte, limit int64) ([]byte, error) {
	defer r.Body.Close()
	body, err := io.ReadAll(io.LimitReader(r.Body, limit+1))
	if err != nil {
		return nil, fmt.Errorf("read webhook body: %w", err)
	}
	if int64(len(body)) > limit {
		return nil, fmt.Errorf("webhook body too large")
	}

	mac := hmac.New(sha256.New, secret)
	_, _ = mac.Write(body)
	want := hex.EncodeToString(mac.Sum(nil))
	got := r.Header.Get("X-Acme-Signature")
	if !hmac.Equal([]byte(want), []byte(got)) {
		return nil, fmt.Errorf("invalid webhook signature")
	}
	return body, nil
}

如果对接的第三方在签名头里附带了时间戳或者版本前缀,要严格按照对方公开给出的签名字符串拼接规则组装输入内容,不要直接把示例代码套用到所有不同平台的对接逻辑里。核心逻辑始终保持不变:传入HMAC计算的内容,必须和第三方实际发送过来的原始内容完全一致。

Go Webhook 单次读取路径:Body 原始字节经 HMAC 校验成功后交给 JSON 解析

业务处理器只接收已经校验完成的字节

验签逻辑跑完之后,后续的业务处理器直接用同一份body做结构化解析即可。这样业务层完全不会接触原始的r.Body,自然不可能出现绕开签名校验逻辑的情况。

type paymentEvent struct {
	ID     string `json:"id"`
	Type   string `json:"type"`
	Amount int64  `json:"amount"`
}

func receivePayment(w http.ResponseWriter, r *http.Request) {
	body, err := readAndCheck(r, webhookSecret, 1

不少老项目改造的时候会把已经读取完的字节重新塞回body里还给r.Body,让旧的处理链路不用修改就能正常运行。这种写法只能作为过渡期的临时兼容方案,需要明确标记资源的负责方,还要避免新旧逻辑同时解析同一次回调事件。更干净的实现思路,是直接把校验完成的可信事件对象作为后续所有逻辑的入口。

签名正确还不够:把重放和重复投递直接挡在业务流程之外

HMAC只能证明消息确实是持有密钥的可信方发送的,本身没办法阻止旧消息被重放后重复投递。支付、订单、订阅这类核心业务场景,还要结合第三方给出的唯一事件ID添加数据库唯一约束;如果对方的签名规则本身附带了时间戳,服务端还要校验时间差在约定的允许范围内,同时把超出时间窗口的情况做好记录。

Go Webhook 信任门:签名、事件时间与事件 ID 依次检查,重复事件在入库前被拦截

  • 签名不匹配:直接返回客户端错误,不解析任何业务字段,也不要把完整的敏感请求内容打印到日志里。
  • 请求时间过旧:按照对接平台的约定规则直接拒绝,或者转到人工复查队列,不要把过期重放的请求当成新事件处理。
  • 事件已经存在:直接返回幂等成功的结果,避免出现重复扣款、重复发货、重复发通知这类异常情况。
  • 数据库暂时不可用:直接返回对应状态码让对方按照协议约定重试,不要直接给对方返回成功结果,后续再异步尝试落库。

上线前用四个验证项确认边界没有遗漏

测试不需要一上来就跑复杂压测。准备一份符合真实格式的测试回调,先故意修改一个字节的内容,确认验签逻辑直接报错;再发送一份超过读取上限的超大请求体,确认程序能在设置的读取阈值处直接截断停止;接着重复发送同一份带相同事件ID的回调,确认数据库里不会生成第二条重复的业务记录;最后模拟数据库短暂不可用的场景,确认返回的状态码完全符合对接平台的重试约定。

日志里只需要保留请求ID、事件ID、事件类型、签名结果和处理耗时就足够支撑问题排查,密钥、完整的敏感支付信息和全量原始请求体,不要放到常规日志输出里。把请求体读取入口收拢的操作看起来很普通,却能避免排查问题过程中不小心把敏感数据扩散到更多非预期的位置。

相关问答

Webhook一定要把body全部读进内存吗?

不一定。如果对接平台的签名算法支持流式计算,你可以用带读取上限的读取器一边更新HMAC校验值,一边把内容写入可控的缓冲区或者临时存储,前提是后续业务解析用到的所有数据都来自已经校验通过的内容,同时临时存储要配套定期清理策略。

为什么不能先执行json.Unmarshal再做签名校验?

JSON格式里的空格、键的排列顺序、数字的不同表示写法,在重新编码的时候都可能发生变化。签名本身是针对传输过程中的原始字节计算出来的,哪怕你重新拼接回去的JSON语义和原始内容完全一致,最后算出来的摘要也可能和对方传过来的对不上。

普通的==比较签名会有什么问题?

普通字符串相等判断会在找到第一个不一样的字节时直接返回结果,不同输入的比对耗时差异可能被外部攻击者利用,逐步逆推出完整的签名内容。面向外部不可信输入的签名校验,使用hmac.Equal更合适,它会按固定逻辑比对等长的摘要内容,不会暴露时序差。

验签成功之后还要校验事件类型吗?

要。签名只能证明消息来自可信对接方,不代表当前接口就需要处理对方发送的所有事件类型。你要提前定义好当前接口允许处理的事件类型白名单,遇到未知类型的事件记录好可追溯的信息之后,直接安全忽略或者拒绝即可。

收尾检查

把原始请求体的读取权限收拢到同一个函数,Webhook的三个核心边界就全部梳理清楚:传入HMAC计算的字节完全没有被修改,业务解析流程只会处理已经校验通过的内容,重复投递的请求不会多次修改业务状态。后续接入新的第三方回调平台的时候,只需要按照对方的签名拼接规则和重试约定替换边缘适配逻辑就行,整条可信数据链路不需要重新编写。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>