当前位置:首页 > 文章列表 > Golang > Go问答 > Go Webhook 验签后 JSON 解析为空:把 r.Body 的读取边界收回到一处

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

来源:17golang原创 2026-07-19 15:08:19 0浏览 收藏

对接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删除
Go 处理超大 JSON 怎么降峰值内存:json.Decoder 流式读取、批次落库与压测对比Go 处理超大 JSON 怎么降峰值内存:json.Decoder 流式读取、批次落库与压测对比
上一篇
Go 处理超大 JSON 怎么降峰值内存:json.Decoder 流式读取、批次落库与压测对比
MySQL 软删除后唯一索引怎么设计:保留历史记录,也允许同邮箱再次注册
下一篇
MySQL 软删除后唯一索引怎么设计:保留历史记录,也允许同邮箱再次注册
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • SuperCLUE中文大模型评测基准:功能、能力维度与应用指南
    SuperCLUE
    SuperCLUE是权威的中文大语言模型综合评测基准,涵盖语言理解、知识应用、AI Agent智能体及安全性等12项核心能力。通过多轮对话与客观测试,定期发布榜单与技术报告,为模型研发、优化及行业选型提供科学依据。
    147次使用
  • C-Eval中文评测基准:大语言模型多学科能力评估指南
    C-Eval
    深入了解C-Eval中文评估套件,涵盖52个学科与4级难度。本文详解其功能特点、Zero-shot/Few-shot使用方法及代码示例,助您全面评测LLM中文理解与泛化能力。
    72次使用
  • ClickPrompt:AI提示词生成与优化工具,支持Stable Diffusion、ChatGPT及代码辅助
    ClickPrompt
    ClickPrompt是一款专为AI提示词编写者设计的开源在线工具,支持Stable Diffusion绘图、ChatGPT对话及GitHub Copilot代码辅助。提供Prompt自动生成、一键运行、社区分享及可视化优化功能,帮助用户高效获取精准AI输出。
    35次使用
  • PromptHero官网:AI提示词搜索、优化与学习平台,支持Midjourney/Stable Diffusion
    PromptHero
    PromptHero是专业的AI提示词搜索引擎与优化平台,支持Stable Diffusion、Midjourney等主流模型。提供海量提示词库、分类搜索、在线课程及社区互动,助力用户高效生成高质量AI图像与文本。
    18次使用
  • OpenArt免费开源指南:Stable Diffusion Prompt Book提示词手册详解
    Stable Diffusion Prompt Book
    深入解析OpenArt推出的Stable Diffusion Prompt Book,这本免费的开源提示词指南涵盖从基础语法到高级技巧,提供风格化词库与参数建议,助您优化AI绘画生成效果。
    21次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议隐私政策
返回登录
  • 重置密码